From 355d028099d76809f1eb69d5eb1c2a80465b3aae Mon Sep 17 00:00:00 2001 From: vivet Date: Sun, 20 Sep 2026 17:24:59 +0200 Subject: [PATCH 01/13] Updated skill, prompts and AGENTS.md --- .github/workflows/build-and-deploy.yml | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Api.ApiClients.Audit/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Api.ApiClients.Entity/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Api.ApiClients.RootLogIn/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Api.ApiClients/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Api.Auth.External.Custom/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Api.Auth.External.Microsoft/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Api.Auth.RootLogin/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Api.Authorization/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Api.ContentNegotiation/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Api.Cookies/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Api.CustomConfigSection/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Api.CustomMiddleware/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Api.CustomService/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Api.Data.Audit/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Api.Data.EntityEvents/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Api.Data.Identity.Auth.ApiKey/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ .../AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Api.Data.Identity.Auth.Jwt/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Api.Data.Identity/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Api.Data.InMemory/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Api.Data.LazyLoading/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Api.Data.MySql.Collation/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Api.Data.MySql.Mappings/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Api.Data.MySql.Spatial/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Api.Data.MySql.StoredProcedures/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Api.Data.MySql.Views/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Api.Data.MySql/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Api.Data.PostgreSQL.Spatial/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Api.Data.PostgreSQL/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Api.Data.Repository.AutoSave/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Api.Data.Repository.Includes/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Api.Data.SoftDelete/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Api.Data.SqLite/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Api.Data.SqlServer.Spatial/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Api.Data.SqlServer/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Api.Data.Triggers/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Api.Documentation.Csp/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Api.Documentation/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Api.ErrorHandling/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Api.Eventing.RabbitMq/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Api.HealthChecks/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Api.Hosting.Http/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Api.Hosting.Https/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Api.Hosting.MultipartLimits/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Api.Localization/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Api.Logging.Log4Net/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Api.Logging.Microsoft/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Api.Logging.NLog/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Api.Logging.Serilog/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Api.Metrics/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Api.MultipartJson/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ .../AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ .../AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Api.PolicyHeaders.Cors/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Api.PolicyHeaders.ForwardedHeaders/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Api.PolicyHeaders.FrameOptions/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Api.PolicyHeaders.Hsts/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Api.PolicyHeaders.ReferrerPolicy/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Api.PolicyHeaders.Robots/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Api.PolicyHeaders.XssProtection/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Api.RequestTracing/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Api.ResponseCache/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Api.ResponseCompression/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Api.Session/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Api.StartupTasks/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Api.StaticFiles/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Api.Storage.Azure/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Api.Storage.Local/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Api.TimeZone/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Api.Versioning/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Api.VirusScan/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Api._Blank/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Console.CustomConfigSection/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Console.CustomService/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Console.Data.InMemory/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Console.Data.MySql/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Console.Data.PostgreSQL/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Console.Data.SqLite/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Console.Data.SqlServer/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Console.Eventing.RabbitMq/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Console.ExceptionHandling/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Console.Localization/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Console.Logging.Log4Net/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Console.Logging.Microsoft/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Console.Logging.NLog/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Console.Logging.Serilog/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Console.StartupTasks/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Console.Storage.Azure/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Console.Storage.Local/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Console.Workers/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Console._Blank/AGENTS.md | 2 +- .../nano-add-postman-collection/SKILL.md | 202 ++++++++++++++++++ .../nano-add-postman-collection.prompt.md | 202 ++++++++++++++++++ Web._Blank/AGENTS.md | 2 +- 277 files changed, 37261 insertions(+), 93 deletions(-) create mode 100644 Api.ApiClients.Audit/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Api.ApiClients.Audit/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Api.ApiClients.Entity/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Api.ApiClients.Entity/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Api.ApiClients.RootLogIn/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Api.ApiClients.RootLogIn/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Api.ApiClients/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Api.ApiClients/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Api.Auth.External.Custom/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Api.Auth.External.Custom/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Api.Auth.External.Microsoft/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Api.Auth.External.Microsoft/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Api.Auth.RootLogin/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Api.Auth.RootLogin/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Api.Authorization/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Api.Authorization/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Api.ContentNegotiation/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Api.ContentNegotiation/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Api.Cookies/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Api.Cookies/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Api.CustomConfigSection/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Api.CustomConfigSection/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Api.CustomMiddleware/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Api.CustomMiddleware/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Api.CustomService/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Api.CustomService/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Api.Data.Audit/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Api.Data.Audit/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Api.Data.EntityEvents/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Api.Data.EntityEvents/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Api.Data.Identity/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Api.Data.Identity/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Api.Data.InMemory/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Api.Data.InMemory/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Api.Data.LazyLoading/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Api.Data.LazyLoading/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Api.Data.MySql.Collation/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Api.Data.MySql.Collation/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Api.Data.MySql.Mappings/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Api.Data.MySql.Mappings/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Api.Data.MySql.Spatial/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Api.Data.MySql.Spatial/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Api.Data.MySql.Views/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Api.Data.MySql.Views/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Api.Data.MySql/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Api.Data.MySql/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Api.Data.PostgreSQL/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Api.Data.PostgreSQL/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Api.Data.Repository.AutoSave/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Api.Data.Repository.AutoSave/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Api.Data.Repository.Includes/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Api.Data.Repository.Includes/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Api.Data.SoftDelete/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Api.Data.SoftDelete/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Api.Data.SqLite/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Api.Data.SqLite/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Api.Data.SqlServer.Spatial/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Api.Data.SqlServer.Spatial/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Api.Data.SqlServer/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Api.Data.SqlServer/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Api.Data.Triggers/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Api.Data.Triggers/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Api.Documentation.Csp/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Api.Documentation.Csp/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Api.Documentation/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Api.Documentation/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Api.ErrorHandling/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Api.ErrorHandling/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Api.Eventing.RabbitMq/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Api.Eventing.RabbitMq/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Api.HealthChecks/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Api.HealthChecks/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Api.Hosting.Http/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Api.Hosting.Http/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Api.Hosting.Https/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Api.Hosting.Https/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Api.Hosting.MultipartLimits/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Api.Hosting.MultipartLimits/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Api.Localization/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Api.Localization/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Api.Logging.Log4Net/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Api.Logging.Log4Net/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Api.Logging.Microsoft/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Api.Logging.Microsoft/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Api.Logging.NLog/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Api.Logging.NLog/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Api.Logging.Serilog/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Api.Logging.Serilog/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Api.Metrics/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Api.Metrics/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Api.MultipartJson/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Api.MultipartJson/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Api.PolicyHeaders.Cors/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Api.PolicyHeaders.Cors/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Api.PolicyHeaders.Hsts/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Api.PolicyHeaders.Hsts/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Api.PolicyHeaders.Robots/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Api.PolicyHeaders.Robots/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Api.RequestTracing/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Api.RequestTracing/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Api.ResponseCache/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Api.ResponseCache/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Api.ResponseCompression/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Api.ResponseCompression/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Api.Session/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Api.Session/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Api.StartupTasks/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Api.StartupTasks/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Api.StaticFiles/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Api.StaticFiles/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Api.Storage.Azure/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Api.Storage.Azure/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Api.Storage.Local/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Api.Storage.Local/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Api.TimeZone/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Api.TimeZone/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Api.Versioning/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Api.Versioning/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Api.VirusScan/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Api.VirusScan/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Api._Blank/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Api._Blank/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Console.CustomConfigSection/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Console.CustomConfigSection/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Console.CustomService/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Console.CustomService/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Console.Data.InMemory/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Console.Data.InMemory/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Console.Data.MySql/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Console.Data.MySql/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Console.Data.PostgreSQL/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Console.Data.PostgreSQL/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Console.Data.SqLite/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Console.Data.SqLite/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Console.Data.SqlServer/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Console.Data.SqlServer/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Console.Eventing.RabbitMq/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Console.Eventing.RabbitMq/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Console.ExceptionHandling/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Console.ExceptionHandling/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Console.Localization/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Console.Localization/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Console.Logging.Log4Net/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Console.Logging.Log4Net/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Console.Logging.Microsoft/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Console.Logging.Microsoft/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Console.Logging.NLog/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Console.Logging.NLog/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Console.Logging.Serilog/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Console.Logging.Serilog/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Console.StartupTasks/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Console.StartupTasks/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Console.Storage.Azure/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Console.Storage.Azure/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Console.Storage.Local/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Console.Storage.Local/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Console.Workers/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Console.Workers/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Console._Blank/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Console._Blank/.github/prompts/nano-add-postman-collection.prompt.md create mode 100644 Web._Blank/.claude/skills/nano-add-postman-collection/SKILL.md create mode 100644 Web._Blank/.github/prompts/nano-add-postman-collection.prompt.md diff --git a/.github/workflows/build-and-deploy.yml b/.github/workflows/build-and-deploy.yml index c6f00ac0..8984f169 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.7 + VERSION: 10.0.8 jobs: build-and-deploy: runs-on: ubuntu-latest 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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Api.ApiClients.Audit/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. 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 new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Api.ApiClients.Audit/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.ApiClients.Audit/AGENTS.md b/Api.ApiClients.Audit/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Api.ApiClients.Audit/AGENTS.md +++ b/Api.ApiClients.Audit/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Api.ApiClients.Entity/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. 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 new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Api.ApiClients.Entity/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.ApiClients.Entity/AGENTS.md b/Api.ApiClients.Entity/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Api.ApiClients.Entity/AGENTS.md +++ b/Api.ApiClients.Entity/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Api.ApiClients.RootLogIn/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. 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 new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.ApiClients.RootLogIn/AGENTS.md b/Api.ApiClients.RootLogIn/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Api.ApiClients.RootLogIn/AGENTS.md +++ b/Api.ApiClients.RootLogIn/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Api.ApiClients/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.ApiClients/.github/prompts/nano-add-postman-collection.prompt.md b/Api.ApiClients/.github/prompts/nano-add-postman-collection.prompt.md new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Api.ApiClients/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.ApiClients/AGENTS.md b/Api.ApiClients/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Api.ApiClients/AGENTS.md +++ b/Api.ApiClients/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Api.Auth.External.Custom/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. 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 new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Api.Auth.External.Custom/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.Auth.External.Custom/AGENTS.md b/Api.Auth.External.Custom/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Api.Auth.External.Custom/AGENTS.md +++ b/Api.Auth.External.Custom/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Api.Auth.External.Microsoft/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. 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 new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Api.Auth.External.Microsoft/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.Auth.External.Microsoft/AGENTS.md b/Api.Auth.External.Microsoft/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Api.Auth.External.Microsoft/AGENTS.md +++ b/Api.Auth.External.Microsoft/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Api.Auth.RootLogin/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. 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 new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Api.Auth.RootLogin/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.Auth.RootLogin/AGENTS.md b/Api.Auth.RootLogin/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Api.Auth.RootLogin/AGENTS.md +++ b/Api.Auth.RootLogin/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Api.Authorization/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.Authorization/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Authorization/.github/prompts/nano-add-postman-collection.prompt.md new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Api.Authorization/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.Authorization/AGENTS.md b/Api.Authorization/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Api.Authorization/AGENTS.md +++ b/Api.Authorization/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Api.ContentNegotiation/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.ContentNegotiation/.github/prompts/nano-add-postman-collection.prompt.md b/Api.ContentNegotiation/.github/prompts/nano-add-postman-collection.prompt.md new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Api.ContentNegotiation/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.ContentNegotiation/AGENTS.md b/Api.ContentNegotiation/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Api.ContentNegotiation/AGENTS.md +++ b/Api.ContentNegotiation/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Api.Cookies/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.Cookies/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Cookies/.github/prompts/nano-add-postman-collection.prompt.md new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Api.Cookies/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.Cookies/AGENTS.md b/Api.Cookies/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Api.Cookies/AGENTS.md +++ b/Api.Cookies/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Api.CustomConfigSection/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.CustomConfigSection/.github/prompts/nano-add-postman-collection.prompt.md b/Api.CustomConfigSection/.github/prompts/nano-add-postman-collection.prompt.md new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Api.CustomConfigSection/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.CustomConfigSection/AGENTS.md b/Api.CustomConfigSection/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Api.CustomConfigSection/AGENTS.md +++ b/Api.CustomConfigSection/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Api.CustomMiddleware/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.CustomMiddleware/.github/prompts/nano-add-postman-collection.prompt.md b/Api.CustomMiddleware/.github/prompts/nano-add-postman-collection.prompt.md new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Api.CustomMiddleware/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.CustomMiddleware/AGENTS.md b/Api.CustomMiddleware/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Api.CustomMiddleware/AGENTS.md +++ b/Api.CustomMiddleware/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Api.CustomService/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.CustomService/.github/prompts/nano-add-postman-collection.prompt.md b/Api.CustomService/.github/prompts/nano-add-postman-collection.prompt.md new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Api.CustomService/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.CustomService/AGENTS.md b/Api.CustomService/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Api.CustomService/AGENTS.md +++ b/Api.CustomService/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Api.Data.Audit/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. 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 new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Api.Data.Audit/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.Data.Audit/AGENTS.md b/Api.Data.Audit/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Api.Data.Audit/AGENTS.md +++ b/Api.Data.Audit/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Api.Data.EntityEvents/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. 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 new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Api.Data.EntityEvents/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.Data.EntityEvents/AGENTS.md b/Api.Data.EntityEvents/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Api.Data.EntityEvents/AGENTS.md +++ b/Api.Data.EntityEvents/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. 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 new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.Data.Identity.Auth.ApiKey/AGENTS.md b/Api.Data.Identity.Auth.ApiKey/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Api.Data.Identity.Auth.ApiKey/AGENTS.md +++ b/Api.Data.Identity.Auth.ApiKey/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. 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 new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.Data.Identity.Auth.External.Custom/AGENTS.md b/Api.Data.Identity.Auth.External.Custom/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Api.Data.Identity.Auth.External.Custom/AGENTS.md +++ b/Api.Data.Identity.Auth.External.Custom/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. 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 new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.Data.Identity.Auth.Jwt/AGENTS.md b/Api.Data.Identity.Auth.Jwt/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Api.Data.Identity.Auth.Jwt/AGENTS.md +++ b/Api.Data.Identity.Auth.Jwt/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Api.Data.Identity/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. 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 new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Api.Data.Identity/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.Data.Identity/AGENTS.md b/Api.Data.Identity/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Api.Data.Identity/AGENTS.md +++ b/Api.Data.Identity/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Api.Data.InMemory/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. 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 new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Api.Data.InMemory/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.Data.InMemory/AGENTS.md b/Api.Data.InMemory/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Api.Data.InMemory/AGENTS.md +++ b/Api.Data.InMemory/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Api.Data.LazyLoading/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. 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 new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Api.Data.LazyLoading/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.Data.LazyLoading/AGENTS.md b/Api.Data.LazyLoading/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Api.Data.LazyLoading/AGENTS.md +++ b/Api.Data.LazyLoading/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Api.Data.MySql.Collation/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. 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 new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Api.Data.MySql.Collation/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.Data.MySql.Collation/AGENTS.md b/Api.Data.MySql.Collation/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Api.Data.MySql.Collation/AGENTS.md +++ b/Api.Data.MySql.Collation/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Api.Data.MySql.Mappings/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. 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 new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Api.Data.MySql.Mappings/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.Data.MySql.Mappings/AGENTS.md b/Api.Data.MySql.Mappings/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Api.Data.MySql.Mappings/AGENTS.md +++ b/Api.Data.MySql.Mappings/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Api.Data.MySql.Spatial/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. 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 new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Api.Data.MySql.Spatial/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.Data.MySql.Spatial/AGENTS.md b/Api.Data.MySql.Spatial/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Api.Data.MySql.Spatial/AGENTS.md +++ b/Api.Data.MySql.Spatial/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. 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 new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.Data.MySql.StoredProcedures/AGENTS.md b/Api.Data.MySql.StoredProcedures/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Api.Data.MySql.StoredProcedures/AGENTS.md +++ b/Api.Data.MySql.StoredProcedures/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Api.Data.MySql.Views/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. 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 new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Api.Data.MySql.Views/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.Data.MySql.Views/AGENTS.md b/Api.Data.MySql.Views/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Api.Data.MySql.Views/AGENTS.md +++ b/Api.Data.MySql.Views/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Api.Data.MySql/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. 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 new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Api.Data.MySql/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.Data.MySql/AGENTS.md b/Api.Data.MySql/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Api.Data.MySql/AGENTS.md +++ b/Api.Data.MySql/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. 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 new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.Data.PostgreSQL.Spatial/AGENTS.md b/Api.Data.PostgreSQL.Spatial/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Api.Data.PostgreSQL.Spatial/AGENTS.md +++ b/Api.Data.PostgreSQL.Spatial/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Api.Data.PostgreSQL/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. 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 new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Api.Data.PostgreSQL/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.Data.PostgreSQL/AGENTS.md b/Api.Data.PostgreSQL/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Api.Data.PostgreSQL/AGENTS.md +++ b/Api.Data.PostgreSQL/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Api.Data.Repository.AutoSave/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. 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 new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Api.Data.Repository.AutoSave/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.Data.Repository.AutoSave/AGENTS.md b/Api.Data.Repository.AutoSave/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Api.Data.Repository.AutoSave/AGENTS.md +++ b/Api.Data.Repository.AutoSave/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Api.Data.Repository.Includes/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. 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 new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Api.Data.Repository.Includes/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.Data.Repository.Includes/AGENTS.md b/Api.Data.Repository.Includes/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Api.Data.Repository.Includes/AGENTS.md +++ b/Api.Data.Repository.Includes/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Api.Data.SoftDelete/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. 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 new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Api.Data.SoftDelete/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.Data.SoftDelete/AGENTS.md b/Api.Data.SoftDelete/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Api.Data.SoftDelete/AGENTS.md +++ b/Api.Data.SoftDelete/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Api.Data.SqLite/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. 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 new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Api.Data.SqLite/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.Data.SqLite/AGENTS.md b/Api.Data.SqLite/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Api.Data.SqLite/AGENTS.md +++ b/Api.Data.SqLite/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. 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 new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.Data.SqlServer.Spatial/AGENTS.md b/Api.Data.SqlServer.Spatial/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Api.Data.SqlServer.Spatial/AGENTS.md +++ b/Api.Data.SqlServer.Spatial/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Api.Data.SqlServer/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. 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 new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Api.Data.SqlServer/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.Data.SqlServer/AGENTS.md b/Api.Data.SqlServer/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Api.Data.SqlServer/AGENTS.md +++ b/Api.Data.SqlServer/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Api.Data.Triggers/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. 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 new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Api.Data.Triggers/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.Data.Triggers/AGENTS.md b/Api.Data.Triggers/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Api.Data.Triggers/AGENTS.md +++ b/Api.Data.Triggers/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Api.Documentation.Csp/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. 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 new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Api.Documentation.Csp/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.Documentation.Csp/AGENTS.md b/Api.Documentation.Csp/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Api.Documentation.Csp/AGENTS.md +++ b/Api.Documentation.Csp/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Api.Documentation/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.Documentation/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Documentation/.github/prompts/nano-add-postman-collection.prompt.md new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Api.Documentation/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.Documentation/AGENTS.md b/Api.Documentation/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Api.Documentation/AGENTS.md +++ b/Api.Documentation/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Api.ErrorHandling/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.ErrorHandling/.github/prompts/nano-add-postman-collection.prompt.md b/Api.ErrorHandling/.github/prompts/nano-add-postman-collection.prompt.md new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Api.ErrorHandling/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.ErrorHandling/AGENTS.md b/Api.ErrorHandling/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Api.ErrorHandling/AGENTS.md +++ b/Api.ErrorHandling/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Api.Eventing.RabbitMq/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. 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 new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Api.Eventing.RabbitMq/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.Eventing.RabbitMq/AGENTS.md b/Api.Eventing.RabbitMq/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Api.Eventing.RabbitMq/AGENTS.md +++ b/Api.Eventing.RabbitMq/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Api.HealthChecks/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.HealthChecks/.github/prompts/nano-add-postman-collection.prompt.md b/Api.HealthChecks/.github/prompts/nano-add-postman-collection.prompt.md new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Api.HealthChecks/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.HealthChecks/AGENTS.md b/Api.HealthChecks/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Api.HealthChecks/AGENTS.md +++ b/Api.HealthChecks/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Api.Hosting.Http/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. 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 new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Api.Hosting.Http/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.Hosting.Http/AGENTS.md b/Api.Hosting.Http/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Api.Hosting.Http/AGENTS.md +++ b/Api.Hosting.Http/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Api.Hosting.Https/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. 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 new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Api.Hosting.Https/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.Hosting.Https/AGENTS.md b/Api.Hosting.Https/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Api.Hosting.Https/AGENTS.md +++ b/Api.Hosting.Https/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Api.Hosting.MultipartLimits/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. 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 new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Api.Hosting.MultipartLimits/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.Hosting.MultipartLimits/AGENTS.md b/Api.Hosting.MultipartLimits/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Api.Hosting.MultipartLimits/AGENTS.md +++ b/Api.Hosting.MultipartLimits/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Api.Localization/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.Localization/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Localization/.github/prompts/nano-add-postman-collection.prompt.md new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Api.Localization/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.Localization/AGENTS.md b/Api.Localization/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Api.Localization/AGENTS.md +++ b/Api.Localization/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Api.Logging.Log4Net/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. 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 new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Api.Logging.Log4Net/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.Logging.Log4Net/AGENTS.md b/Api.Logging.Log4Net/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Api.Logging.Log4Net/AGENTS.md +++ b/Api.Logging.Log4Net/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Api.Logging.Microsoft/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. 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 new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Api.Logging.Microsoft/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.Logging.Microsoft/AGENTS.md b/Api.Logging.Microsoft/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Api.Logging.Microsoft/AGENTS.md +++ b/Api.Logging.Microsoft/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Api.Logging.NLog/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. 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 new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Api.Logging.NLog/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.Logging.NLog/AGENTS.md b/Api.Logging.NLog/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Api.Logging.NLog/AGENTS.md +++ b/Api.Logging.NLog/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Api.Logging.Serilog/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. 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 new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Api.Logging.Serilog/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.Logging.Serilog/AGENTS.md b/Api.Logging.Serilog/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Api.Logging.Serilog/AGENTS.md +++ b/Api.Logging.Serilog/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Api.Metrics/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.Metrics/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Metrics/.github/prompts/nano-add-postman-collection.prompt.md new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Api.Metrics/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.Metrics/AGENTS.md b/Api.Metrics/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Api.Metrics/AGENTS.md +++ b/Api.Metrics/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Api.MultipartJson/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.MultipartJson/.github/prompts/nano-add-postman-collection.prompt.md b/Api.MultipartJson/.github/prompts/nano-add-postman-collection.prompt.md new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Api.MultipartJson/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.MultipartJson/AGENTS.md b/Api.MultipartJson/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Api.MultipartJson/AGENTS.md +++ b/Api.MultipartJson/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. 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 new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.PolicyHeaders.ContentSecurityPolicy/AGENTS.md b/Api.PolicyHeaders.ContentSecurityPolicy/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Api.PolicyHeaders.ContentSecurityPolicy/AGENTS.md +++ b/Api.PolicyHeaders.ContentSecurityPolicy/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. 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 new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.PolicyHeaders.ContentTypeOptions/AGENTS.md b/Api.PolicyHeaders.ContentTypeOptions/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Api.PolicyHeaders.ContentTypeOptions/AGENTS.md +++ b/Api.PolicyHeaders.ContentTypeOptions/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Api.PolicyHeaders.Cors/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. 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 new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Api.PolicyHeaders.Cors/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.PolicyHeaders.Cors/AGENTS.md b/Api.PolicyHeaders.Cors/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Api.PolicyHeaders.Cors/AGENTS.md +++ b/Api.PolicyHeaders.Cors/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. 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 new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.PolicyHeaders.ForwardedHeaders/AGENTS.md b/Api.PolicyHeaders.ForwardedHeaders/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Api.PolicyHeaders.ForwardedHeaders/AGENTS.md +++ b/Api.PolicyHeaders.ForwardedHeaders/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. 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 new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.PolicyHeaders.FrameOptions/AGENTS.md b/Api.PolicyHeaders.FrameOptions/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Api.PolicyHeaders.FrameOptions/AGENTS.md +++ b/Api.PolicyHeaders.FrameOptions/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. 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 new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.PolicyHeaders.Hsts/AGENTS.md b/Api.PolicyHeaders.Hsts/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Api.PolicyHeaders.Hsts/AGENTS.md +++ b/Api.PolicyHeaders.Hsts/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. 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 new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.PolicyHeaders.ReferrerPolicy/AGENTS.md b/Api.PolicyHeaders.ReferrerPolicy/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Api.PolicyHeaders.ReferrerPolicy/AGENTS.md +++ b/Api.PolicyHeaders.ReferrerPolicy/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Api.PolicyHeaders.Robots/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. 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 new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Api.PolicyHeaders.Robots/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.PolicyHeaders.Robots/AGENTS.md b/Api.PolicyHeaders.Robots/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Api.PolicyHeaders.Robots/AGENTS.md +++ b/Api.PolicyHeaders.Robots/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. 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 new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.PolicyHeaders.XssProtection/AGENTS.md b/Api.PolicyHeaders.XssProtection/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Api.PolicyHeaders.XssProtection/AGENTS.md +++ b/Api.PolicyHeaders.XssProtection/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Api.RequestTracing/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.RequestTracing/.github/prompts/nano-add-postman-collection.prompt.md b/Api.RequestTracing/.github/prompts/nano-add-postman-collection.prompt.md new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Api.RequestTracing/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.RequestTracing/AGENTS.md b/Api.RequestTracing/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Api.RequestTracing/AGENTS.md +++ b/Api.RequestTracing/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Api.ResponseCache/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.ResponseCache/.github/prompts/nano-add-postman-collection.prompt.md b/Api.ResponseCache/.github/prompts/nano-add-postman-collection.prompt.md new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Api.ResponseCache/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.ResponseCache/AGENTS.md b/Api.ResponseCache/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Api.ResponseCache/AGENTS.md +++ b/Api.ResponseCache/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Api.ResponseCompression/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.ResponseCompression/.github/prompts/nano-add-postman-collection.prompt.md b/Api.ResponseCompression/.github/prompts/nano-add-postman-collection.prompt.md new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Api.ResponseCompression/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.ResponseCompression/AGENTS.md b/Api.ResponseCompression/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Api.ResponseCompression/AGENTS.md +++ b/Api.ResponseCompression/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Api.Session/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.Session/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Session/.github/prompts/nano-add-postman-collection.prompt.md new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Api.Session/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.Session/AGENTS.md b/Api.Session/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Api.Session/AGENTS.md +++ b/Api.Session/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Api.StartupTasks/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.StartupTasks/.github/prompts/nano-add-postman-collection.prompt.md b/Api.StartupTasks/.github/prompts/nano-add-postman-collection.prompt.md new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Api.StartupTasks/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.StartupTasks/AGENTS.md b/Api.StartupTasks/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Api.StartupTasks/AGENTS.md +++ b/Api.StartupTasks/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Api.StaticFiles/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.StaticFiles/.github/prompts/nano-add-postman-collection.prompt.md b/Api.StaticFiles/.github/prompts/nano-add-postman-collection.prompt.md new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Api.StaticFiles/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.StaticFiles/AGENTS.md b/Api.StaticFiles/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Api.StaticFiles/AGENTS.md +++ b/Api.StaticFiles/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Api.Storage.Azure/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. 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 new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Api.Storage.Azure/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.Storage.Azure/AGENTS.md b/Api.Storage.Azure/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Api.Storage.Azure/AGENTS.md +++ b/Api.Storage.Azure/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Api.Storage.Local/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. 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 new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Api.Storage.Local/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.Storage.Local/AGENTS.md b/Api.Storage.Local/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Api.Storage.Local/AGENTS.md +++ b/Api.Storage.Local/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Api.TimeZone/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.TimeZone/.github/prompts/nano-add-postman-collection.prompt.md b/Api.TimeZone/.github/prompts/nano-add-postman-collection.prompt.md new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Api.TimeZone/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.TimeZone/AGENTS.md b/Api.TimeZone/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Api.TimeZone/AGENTS.md +++ b/Api.TimeZone/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Api.Versioning/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.Versioning/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Versioning/.github/prompts/nano-add-postman-collection.prompt.md new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Api.Versioning/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.Versioning/AGENTS.md b/Api.Versioning/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Api.Versioning/AGENTS.md +++ b/Api.Versioning/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Api.VirusScan/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.VirusScan/.github/prompts/nano-add-postman-collection.prompt.md b/Api.VirusScan/.github/prompts/nano-add-postman-collection.prompt.md new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Api.VirusScan/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api.VirusScan/AGENTS.md b/Api.VirusScan/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Api.VirusScan/AGENTS.md +++ b/Api.VirusScan/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Api._Blank/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api._Blank/.github/prompts/nano-add-postman-collection.prompt.md b/Api._Blank/.github/prompts/nano-add-postman-collection.prompt.md new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Api._Blank/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Api._Blank/AGENTS.md b/Api._Blank/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Api._Blank/AGENTS.md +++ b/Api._Blank/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Console.CustomConfigSection/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Console.CustomConfigSection/.github/prompts/nano-add-postman-collection.prompt.md b/Console.CustomConfigSection/.github/prompts/nano-add-postman-collection.prompt.md new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Console.CustomConfigSection/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Console.CustomConfigSection/AGENTS.md b/Console.CustomConfigSection/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Console.CustomConfigSection/AGENTS.md +++ b/Console.CustomConfigSection/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Console.CustomService/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Console.CustomService/.github/prompts/nano-add-postman-collection.prompt.md b/Console.CustomService/.github/prompts/nano-add-postman-collection.prompt.md new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Console.CustomService/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Console.CustomService/AGENTS.md b/Console.CustomService/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Console.CustomService/AGENTS.md +++ b/Console.CustomService/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Console.Data.InMemory/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. 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 new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Console.Data.InMemory/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Console.Data.InMemory/AGENTS.md b/Console.Data.InMemory/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Console.Data.InMemory/AGENTS.md +++ b/Console.Data.InMemory/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Console.Data.MySql/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. 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 new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Console.Data.MySql/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Console.Data.MySql/AGENTS.md b/Console.Data.MySql/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Console.Data.MySql/AGENTS.md +++ b/Console.Data.MySql/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Console.Data.PostgreSQL/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. 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 new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Console.Data.PostgreSQL/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Console.Data.PostgreSQL/AGENTS.md b/Console.Data.PostgreSQL/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Console.Data.PostgreSQL/AGENTS.md +++ b/Console.Data.PostgreSQL/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Console.Data.SqLite/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. 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 new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Console.Data.SqLite/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Console.Data.SqLite/AGENTS.md b/Console.Data.SqLite/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Console.Data.SqLite/AGENTS.md +++ b/Console.Data.SqLite/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Console.Data.SqlServer/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. 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 new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Console.Data.SqlServer/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Console.Data.SqlServer/AGENTS.md b/Console.Data.SqlServer/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Console.Data.SqlServer/AGENTS.md +++ b/Console.Data.SqlServer/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Console.Eventing.RabbitMq/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. 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 new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Console.Eventing.RabbitMq/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Console.Eventing.RabbitMq/AGENTS.md b/Console.Eventing.RabbitMq/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Console.Eventing.RabbitMq/AGENTS.md +++ b/Console.Eventing.RabbitMq/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Console.ExceptionHandling/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Console.ExceptionHandling/.github/prompts/nano-add-postman-collection.prompt.md b/Console.ExceptionHandling/.github/prompts/nano-add-postman-collection.prompt.md new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Console.ExceptionHandling/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Console.ExceptionHandling/AGENTS.md b/Console.ExceptionHandling/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Console.ExceptionHandling/AGENTS.md +++ b/Console.ExceptionHandling/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Console.Localization/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Console.Localization/.github/prompts/nano-add-postman-collection.prompt.md b/Console.Localization/.github/prompts/nano-add-postman-collection.prompt.md new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Console.Localization/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Console.Localization/AGENTS.md b/Console.Localization/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Console.Localization/AGENTS.md +++ b/Console.Localization/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Console.Logging.Log4Net/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. 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 new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Console.Logging.Log4Net/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Console.Logging.Log4Net/AGENTS.md b/Console.Logging.Log4Net/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Console.Logging.Log4Net/AGENTS.md +++ b/Console.Logging.Log4Net/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Console.Logging.Microsoft/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. 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 new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Console.Logging.Microsoft/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Console.Logging.Microsoft/AGENTS.md b/Console.Logging.Microsoft/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Console.Logging.Microsoft/AGENTS.md +++ b/Console.Logging.Microsoft/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Console.Logging.NLog/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. 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 new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Console.Logging.NLog/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Console.Logging.NLog/AGENTS.md b/Console.Logging.NLog/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Console.Logging.NLog/AGENTS.md +++ b/Console.Logging.NLog/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Console.Logging.Serilog/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. 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 new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Console.Logging.Serilog/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Console.Logging.Serilog/AGENTS.md b/Console.Logging.Serilog/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Console.Logging.Serilog/AGENTS.md +++ b/Console.Logging.Serilog/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Console.StartupTasks/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Console.StartupTasks/.github/prompts/nano-add-postman-collection.prompt.md b/Console.StartupTasks/.github/prompts/nano-add-postman-collection.prompt.md new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Console.StartupTasks/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Console.StartupTasks/AGENTS.md b/Console.StartupTasks/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Console.StartupTasks/AGENTS.md +++ b/Console.StartupTasks/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Console.Storage.Azure/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. 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 new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Console.Storage.Azure/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Console.Storage.Azure/AGENTS.md b/Console.Storage.Azure/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Console.Storage.Azure/AGENTS.md +++ b/Console.Storage.Azure/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Console.Storage.Local/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. 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 new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Console.Storage.Local/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Console.Storage.Local/AGENTS.md b/Console.Storage.Local/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Console.Storage.Local/AGENTS.md +++ b/Console.Storage.Local/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Console.Workers/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Console.Workers/.github/prompts/nano-add-postman-collection.prompt.md b/Console.Workers/.github/prompts/nano-add-postman-collection.prompt.md new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Console.Workers/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Console.Workers/AGENTS.md b/Console.Workers/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Console.Workers/AGENTS.md +++ b/Console.Workers/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Console._Blank/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Console._Blank/.github/prompts/nano-add-postman-collection.prompt.md b/Console._Blank/.github/prompts/nano-add-postman-collection.prompt.md new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Console._Blank/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Console._Blank/AGENTS.md b/Console._Blank/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Console._Blank/AGENTS.md +++ b/Console._Blank/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```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 new file mode 100644 index 00000000..1bebfe05 --- /dev/null +++ b/Web._Blank/.claude/skills/nano-add-postman-collection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: nano-add-postman-collection +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 — + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Web._Blank/.github/prompts/nano-add-postman-collection.prompt.md b/Web._Blank/.github/prompts/nano-add-postman-collection.prompt.md new file mode 100644 index 00000000..7064e0b4 --- /dev/null +++ b/Web._Blank/.github/prompts/nano-add-postman-collection.prompt.md @@ -0,0 +1,202 @@ +--- +mode: agent +description: Generate a Postman collection for a Nano.Library-based application - one collection file per application (never a shared multi-app file), covering Auth and every controller's actual generic-action tier plus its custom endpoints, with test scripts chaining entity Ids/names between requests. Use when the user asks to create, generate, or update a Postman collection for a Nano API, Web, or internal service 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 +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, +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. + +## 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 + `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 + 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 + 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, + 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 + 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). + No Create, no Delete. + - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + Create, no 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 + 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 + 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 + 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 + attributes and cite the actual route constant, not a guessed path. Note anything a tester needs + 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. +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 - + the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login + Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s + `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and + `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. +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 + 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 + 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 + a required FK to another entity must come after that entity's Create in the collection, so its + request body can reference the earlier one's chained Id variable (see Variable chaining below). + Skip a pure join/junction entity to the end regardless of alphabetical order, since it always + depends on both sides existing first. + +## Base URL scheme + +| App type | Scheme | Rationale | +|---|---|---| +| Internal service (Svc.*) | `http://{{host}}:{{port}}` | Never publicly exposed - only reachable locally via docker-compose's mapped port, or inside the cluster. `{{port}}` is a single shared environment variable the user manually points at whichever service's docker-compose they're currently running - don't invent a per-app port variable. | +| Public API (Api.*) | `https://{{host}}:{{port_https}}` | Publicly exposed via `nano-add-public-exposure`'s HTTPRoute, terminates TLS. | + +**This determines which environments the collection can be run against at all.** An internal +service's collection only works against the `Development` environment (docker-compose's local +port) - `Staging`/`Production` have no meaning for it, since nothing routes to an internal service +from outside the cluster. Say this explicitly in the collection's top-level `description` (or the +README section below) rather than letting a Staging run silently produce broken URLs. A Public +API's collection is meant to run against all three. + +## Environments + +**Never create environment files.** Environments live in Postman itself, not the solution - once +imported they're edited and maintained there, independent of the collection's own lifecycle in +git. Instead, tell the user (once per solution - skip this if they've already confirmed the three +environments exist from an earlier run of this skill) that they need three Postman environments +named `Development`, `Staging`, and `Production`, and show this table so they can create/verify +them: + +| Variable | Development | Staging | Production | +|---|---|---|---| +| `host` | `localhost` | *(their real Staging domain)* | *(their real Production domain)* | +| `port` | the internal service's docker-compose port (commonly `8080`) | *(doesn't apply - internal services aren't reachable outside the cluster)* | *(doesn't apply)* | +| `port_https` | the Public API's local dev HTTPS port (commonly `4443`) | `443` | `443` | + +## Keeping the collection in sync with Postman + +The collection JSON file in the solution (see File output below) is the source of truth - never +suggest editing the collection directly inside Postman, since a direct edit there would silently +diverge from the file, or get overwritten unnoticed the next time this skill regenerates it. +Writing the file never puts it into Postman by itself - always tell the user which of these two +they need to do next, since Postman has no way to notice a file changed on disk: + +- **First time for this app**: import the file normally (Postman → Import → select the file). This + creates the collection and assigns it the `_postman_id` this skill's file already carries, so + every later regeneration lines up with the Replace case below. +- **Updating an existing collection**: re-import via **Replace**, not a fresh Import - right-click + the collection in Postman's sidebar → Import → select the updated file. Postman matches by + `info._postman_id` and updates the existing collection in place. A fresh Import instead would + create a duplicate alongside the old one. + +## Generic action routes + +| Action | Method | Route | +|---|---|---| +| Index | GET | `index` | +| Details | GET | `{id}/details` | +| Details Many | POST | `details/many` | +| Query | POST | `query` | +| Query First | POST | `query/first` | +| Query Count | POST | `query/count` | +| Create | POST | `create` | +| Create Or Get | POST | `create/get` | +| Create Or Edit | POST | `create/edit` | +| Create And Reload | POST | `create/reload` | +| Create Many | POST | `create/many` | +| Edit | PUT | `edit` | +| Edit And Reload | PUT | `edit/reload` | +| Edit Many | PUT | `edit/many` | +| Edit Query | PUT | `edit/query` | +| Delete | DELETE | `{id}/delete` | +| Delete Many | DELETE | `delete/many` | +| Delete Query | DELETE | `delete/query` | + +Read/Details/Query-type requests append `?includedepth=4` (Index also gets +`Paging.Number=1&Paging.Count=10&Order.By=CreatedAt&Order.Direction=Desc`). Query/Query First +bodies wrap the entity's own query-criteria fields in `{ "Criteria": {...}, "Paging": {...}, +"Order": {...} }` (Query First omits `Paging`); Query Count and Delete Query send the criteria +object directly, unwrapped. Edit Query sends `{ "Criteria": {...}, "PropertyUpdates": {...} }`. +The entity's route segment is its controller's class name with `Controller` stripped, lowercased, +concatenated with no separator (`OrderLineItemsController` → `orderlineitems`) - matches +`[Route("[controller]")]`'s own naming, not a kebab-case guess. + +## Variable chaining + +For each entity, a Create (or SignUp) request's `test` script stores `{{Entity.Id}}` and one +human-readable distinguishing field (`{{Entity.Name}}`, `{{Entity.Domain}}`, etc.) as collection +variables; a Delete request's `test` script clears them back to `null`. Later entities' Create +bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so the collection can be +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. + +## File output + +- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the + **solution root** - never nested under a `.postman/` subfolder, never combined with another + app's folders into one file. +- Collection `info.name` is the application name; top-level `item` entries are the app's own + folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer + folder named after the app, since the collection itself already carries that name. +- Collection-level `auth` is `bearer` against `{{Auth.Token}}` (or `{{Auth.AdminToken}}` if the + app's Auth folder distinguishes an admin token from a regular user token, following whatever the + app's own login response actually returns). + +## README + +Add a `## Testing with Postman` section to the application's `README.md`, pointing at +`.json` in the solution root, and listing the ordered request flow (matching the +collection's own folder order) with a one-line note per step for anything non-obvious: which +variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and +any business-rule guard worth knowing about before calling it. + +## After making the change + +- Show the request/folder count and the top-level folder list, so the user can sanity-check + coverage without opening the file. +- State plainly whether this app is a Public API or internal service, and which environments the + collection can actually be run against as a result. +- Remind the user to create (or verify) the three Postman environments per the Environments + section above, showing the variable table again - environments live in Postman itself, not a + file this skill can check for, so only skip the reminder if the user already confirmed within + this same conversation that all three exist. +- Tell the user to bring the file into Postman per Keeping the collection in sync above - a fresh + **Import** if this is the first time this app's collection has been generated, or a **Replace** + import if this run updated a collection already in Postman. Don't leave this implicit - writing + the file changes nothing in Postman until the user does one of these two. +- If Root credentials, the local port, or Identity configuration couldn't be found (missing + `appsettings.Development.json` section, no docker-compose file, etc.), say so explicitly rather + than guessing or silently reusing another app's values. +- If exploring the controllers surfaced a real gap (a custom Api Client request with no matching + controller action, a doc comment claiming behavior the code doesn't have, etc.), flag it to the + user rather than quietly working around it in the collection. diff --git a/Web._Blank/AGENTS.md b/Web._Blank/AGENTS.md index 9da98eba..6cb764e0 100644 --- a/Web._Blank/AGENTS.md +++ b/Web._Blank/AGENTS.md @@ -2335,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```json From 5dc11f4f1df493fd3832818d2f67c3a8c4f89287 Mon Sep 17 00:00:00 2001 From: vivet Date: Sun, 20 Sep 2026 17:32:48 +0200 Subject: [PATCH 02/13] Updated --- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- .../skills/nano-add-postman-collection/SKILL.md | 15 ++++++++------- .../prompts/nano-add-postman-collection.prompt.md | 15 ++++++++------- 184 files changed, 1472 insertions(+), 1288 deletions(-) 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 1bebfe05..6e1d1061 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 100644 --- a/Api.ApiClients/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.ApiClients/.claude/skills/nano-add-postman-collection/SKILL.md @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 100644 --- a/Api.ApiClients/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.ApiClients/.github/prompts/nano-add-postman-collection.prompt.md @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 100644 --- a/Api.Authorization/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Authorization/.claude/skills/nano-add-postman-collection/SKILL.md @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 100644 --- a/Api.Authorization/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Authorization/.github/prompts/nano-add-postman-collection.prompt.md @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 100644 --- a/Api.ContentNegotiation/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.ContentNegotiation/.claude/skills/nano-add-postman-collection/SKILL.md @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 100644 --- a/Api.ContentNegotiation/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.ContentNegotiation/.github/prompts/nano-add-postman-collection.prompt.md @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 100644 --- a/Api.Cookies/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Cookies/.claude/skills/nano-add-postman-collection/SKILL.md @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 100644 --- a/Api.Cookies/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Cookies/.github/prompts/nano-add-postman-collection.prompt.md @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 100644 --- a/Api.CustomConfigSection/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.CustomConfigSection/.claude/skills/nano-add-postman-collection/SKILL.md @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 100644 --- a/Api.CustomConfigSection/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.CustomConfigSection/.github/prompts/nano-add-postman-collection.prompt.md @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 100644 --- a/Api.CustomMiddleware/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.CustomMiddleware/.claude/skills/nano-add-postman-collection/SKILL.md @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 100644 --- a/Api.CustomMiddleware/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.CustomMiddleware/.github/prompts/nano-add-postman-collection.prompt.md @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 100644 --- a/Api.CustomService/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.CustomService/.claude/skills/nano-add-postman-collection/SKILL.md @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 100644 --- a/Api.CustomService/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.CustomService/.github/prompts/nano-add-postman-collection.prompt.md @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 100644 --- a/Api.Documentation/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Documentation/.claude/skills/nano-add-postman-collection/SKILL.md @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 100644 --- a/Api.Documentation/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Documentation/.github/prompts/nano-add-postman-collection.prompt.md @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 100644 --- a/Api.ErrorHandling/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.ErrorHandling/.claude/skills/nano-add-postman-collection/SKILL.md @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 100644 --- a/Api.ErrorHandling/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.ErrorHandling/.github/prompts/nano-add-postman-collection.prompt.md @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 100644 --- a/Api.HealthChecks/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.HealthChecks/.claude/skills/nano-add-postman-collection/SKILL.md @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 100644 --- a/Api.HealthChecks/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.HealthChecks/.github/prompts/nano-add-postman-collection.prompt.md @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 100644 --- a/Api.Localization/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Localization/.claude/skills/nano-add-postman-collection/SKILL.md @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 100644 --- a/Api.Localization/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Localization/.github/prompts/nano-add-postman-collection.prompt.md @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 100644 --- a/Api.Metrics/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Metrics/.claude/skills/nano-add-postman-collection/SKILL.md @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 100644 --- a/Api.Metrics/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Metrics/.github/prompts/nano-add-postman-collection.prompt.md @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 100644 --- a/Api.MultipartJson/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.MultipartJson/.claude/skills/nano-add-postman-collection/SKILL.md @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 100644 --- a/Api.MultipartJson/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.MultipartJson/.github/prompts/nano-add-postman-collection.prompt.md @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 100644 --- a/Api.RequestTracing/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.RequestTracing/.claude/skills/nano-add-postman-collection/SKILL.md @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 100644 --- a/Api.RequestTracing/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.RequestTracing/.github/prompts/nano-add-postman-collection.prompt.md @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 100644 --- a/Api.ResponseCache/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.ResponseCache/.claude/skills/nano-add-postman-collection/SKILL.md @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 100644 --- a/Api.ResponseCache/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.ResponseCache/.github/prompts/nano-add-postman-collection.prompt.md @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 100644 --- a/Api.ResponseCompression/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.ResponseCompression/.claude/skills/nano-add-postman-collection/SKILL.md @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 100644 --- a/Api.ResponseCompression/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.ResponseCompression/.github/prompts/nano-add-postman-collection.prompt.md @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 100644 --- a/Api.Session/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Session/.claude/skills/nano-add-postman-collection/SKILL.md @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 100644 --- a/Api.Session/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Session/.github/prompts/nano-add-postman-collection.prompt.md @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 100644 --- a/Api.StartupTasks/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.StartupTasks/.claude/skills/nano-add-postman-collection/SKILL.md @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 100644 --- a/Api.StartupTasks/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.StartupTasks/.github/prompts/nano-add-postman-collection.prompt.md @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 100644 --- a/Api.StaticFiles/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.StaticFiles/.claude/skills/nano-add-postman-collection/SKILL.md @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 100644 --- a/Api.StaticFiles/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.StaticFiles/.github/prompts/nano-add-postman-collection.prompt.md @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 100644 --- a/Api.TimeZone/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.TimeZone/.claude/skills/nano-add-postman-collection/SKILL.md @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 100644 --- a/Api.TimeZone/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.TimeZone/.github/prompts/nano-add-postman-collection.prompt.md @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 100644 --- a/Api.Versioning/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Versioning/.claude/skills/nano-add-postman-collection/SKILL.md @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 100644 --- a/Api.Versioning/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Versioning/.github/prompts/nano-add-postman-collection.prompt.md @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 100644 --- a/Api.VirusScan/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.VirusScan/.claude/skills/nano-add-postman-collection/SKILL.md @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 100644 --- a/Api.VirusScan/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.VirusScan/.github/prompts/nano-add-postman-collection.prompt.md @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 100644 --- a/Api._Blank/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api._Blank/.claude/skills/nano-add-postman-collection/SKILL.md @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 100644 --- a/Api._Blank/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api._Blank/.github/prompts/nano-add-postman-collection.prompt.md @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 100644 --- a/Console.CustomConfigSection/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Console.CustomConfigSection/.claude/skills/nano-add-postman-collection/SKILL.md @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 100644 --- a/Console.CustomConfigSection/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Console.CustomConfigSection/.github/prompts/nano-add-postman-collection.prompt.md @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 100644 --- a/Console.CustomService/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Console.CustomService/.claude/skills/nano-add-postman-collection/SKILL.md @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 100644 --- a/Console.CustomService/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Console.CustomService/.github/prompts/nano-add-postman-collection.prompt.md @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 100644 --- a/Console.ExceptionHandling/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Console.ExceptionHandling/.claude/skills/nano-add-postman-collection/SKILL.md @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 100644 --- a/Console.ExceptionHandling/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Console.ExceptionHandling/.github/prompts/nano-add-postman-collection.prompt.md @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 100644 --- a/Console.Localization/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Console.Localization/.claude/skills/nano-add-postman-collection/SKILL.md @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 100644 --- a/Console.Localization/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Console.Localization/.github/prompts/nano-add-postman-collection.prompt.md @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 100644 --- a/Console.StartupTasks/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Console.StartupTasks/.claude/skills/nano-add-postman-collection/SKILL.md @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 100644 --- a/Console.StartupTasks/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Console.StartupTasks/.github/prompts/nano-add-postman-collection.prompt.md @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 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 @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 100644 --- a/Console.Workers/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Console.Workers/.claude/skills/nano-add-postman-collection/SKILL.md @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 100644 --- a/Console.Workers/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Console.Workers/.github/prompts/nano-add-postman-collection.prompt.md @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 100644 --- a/Console._Blank/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Console._Blank/.claude/skills/nano-add-postman-collection/SKILL.md @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 100644 --- a/Console._Blank/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Console._Blank/.github/prompts/nano-add-postman-collection.prompt.md @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 1bebfe05..6e1d1061 100644 --- a/Web._Blank/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Web._Blank/.claude/skills/nano-add-postman-collection/SKILL.md @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change 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 7064e0b4..86bc975c 100644 --- a/Web._Blank/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Web._Blank/.github/prompts/nano-add-postman-collection.prompt.md @@ -162,9 +162,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## File output -- One file per application: `.json` (e.g. `Svc.Accounts.json`, `Api.Admin.json`) at the - **solution root** - never nested under a `.postman/` subfolder, never combined with another - app's folders into one file. +- One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, + `Postman_Api.Admin.json`), in that application's **own folder** (the same directory as its + `README.md`) - never at the solution root, never nested under a `.postman/` subfolder, never + combined with another app's folders into one file. - Collection `info.name` is the application name; top-level `item` entries are the app's own folders directly (Auth, then each entity in dependency order) - don't wrap them in an outer folder named after the app, since the collection itself already carries that name. @@ -175,10 +176,10 @@ match the actual FK dependency chain (see step 7 above), not alphabetically. ## README Add a `## Testing with Postman` section to the application's `README.md`, pointing at -`.json` in the solution root, and listing the ordered request flow (matching the -collection's own folder order) with a one-line note per step for anything non-obvious: which -variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/read-only and why, and -any business-rule guard worth knowing about before calling it. +`Postman_.json` alongside it in the same folder, and listing the ordered request flow +(matching the collection's own folder order) with a one-line note per step for anything +non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ +read-only and why, and any business-rule guard worth knowing about before calling it. ## After making the change From 10d6242231dca5b193b30d306fa1b651e513a39a Mon Sep 17 00:00:00 2001 From: vivet Date: Tue, 22 Sep 2026 09:25:49 +0200 Subject: [PATCH 03/13] Added docker-compose .env file to all solutions --- Api.ApiClients.Audit/.docker/docker-compose.dcproj | 1 + Api.ApiClients.Audit/.docker/docker-compose.yml | 2 ++ Api.ApiClients.Entity/.docker/docker-compose.dcproj | 1 + Api.ApiClients.Entity/.docker/docker-compose.yml | 2 ++ Api.ApiClients.RootLogIn/.docker/docker-compose.dcproj | 1 + Api.ApiClients.RootLogIn/.docker/docker-compose.yml | 2 ++ Api.ApiClients/.docker/docker-compose.dcproj | 1 + Api.ApiClients/.docker/docker-compose.yml | 2 ++ Api.Auth.External.Custom/.docker/docker-compose.dcproj | 1 + Api.Auth.External.Custom/.docker/docker-compose.yml | 2 ++ Api.Auth.External.Microsoft/.docker/docker-compose.dcproj | 1 + Api.Auth.External.Microsoft/.docker/docker-compose.yml | 2 ++ Api.Auth.RootLogin/.docker/docker-compose.dcproj | 1 + Api.Auth.RootLogin/.docker/docker-compose.yml | 2 ++ Api.Authorization/.docker/docker-compose.dcproj | 1 + Api.Authorization/.docker/docker-compose.yml | 2 ++ Api.ContentNegotiation/.docker/docker-compose.dcproj | 1 + Api.ContentNegotiation/.docker/docker-compose.yml | 2 ++ Api.Cookies/.docker/docker-compose.dcproj | 1 + Api.Cookies/.docker/docker-compose.yml | 2 ++ Api.CustomConfigSection/.docker/docker-compose.dcproj | 1 + Api.CustomConfigSection/.docker/docker-compose.yml | 2 ++ Api.CustomMiddleware/.docker/docker-compose.dcproj | 1 + Api.CustomMiddleware/.docker/docker-compose.yml | 2 ++ Api.CustomService/.docker/docker-compose.dcproj | 1 + Api.CustomService/.docker/docker-compose.yml | 2 ++ Api.Data.Audit/.docker/docker-compose.dcproj | 1 + Api.Data.Audit/.docker/docker-compose.yml | 2 ++ Api.Data.EntityEvents/.docker/docker-compose.dcproj | 1 + Api.Data.EntityEvents/.docker/docker-compose.yml | 2 ++ Api.Data.Identity.Auth.ApiKey/.docker/docker-compose.dcproj | 1 + Api.Data.Identity.Auth.ApiKey/.docker/docker-compose.yml | 2 ++ .../.docker/docker-compose.dcproj | 1 + .../.docker/docker-compose.yml | 2 ++ Api.Data.Identity.Auth.Jwt/.docker/docker-compose.dcproj | 1 + Api.Data.Identity.Auth.Jwt/.docker/docker-compose.yml | 2 ++ Api.Data.Identity/.docker/docker-compose.dcproj | 1 + Api.Data.Identity/.docker/docker-compose.yml | 2 ++ Api.Data.InMemory/.docker/docker-compose.dcproj | 1 + Api.Data.InMemory/.docker/docker-compose.yml | 2 ++ Api.Data.LazyLoading/.docker/docker-compose.dcproj | 1 + Api.Data.LazyLoading/.docker/docker-compose.yml | 2 ++ Api.Data.MySql.Collation/.docker/docker-compose.dcproj | 1 + Api.Data.MySql.Collation/.docker/docker-compose.yml | 2 ++ Api.Data.MySql.Mappings/.docker/docker-compose.dcproj | 1 + Api.Data.MySql.Mappings/.docker/docker-compose.yml | 2 ++ Api.Data.MySql.Spatial/.docker/docker-compose.dcproj | 1 + Api.Data.MySql.Spatial/.docker/docker-compose.yml | 2 ++ Api.Data.MySql.StoredProcedures/.docker/docker-compose.dcproj | 1 + Api.Data.MySql.StoredProcedures/.docker/docker-compose.yml | 2 ++ Api.Data.MySql.Views/.docker/docker-compose.dcproj | 1 + Api.Data.MySql.Views/.docker/docker-compose.yml | 2 ++ Api.Data.MySql/.docker/docker-compose.dcproj | 1 + Api.Data.MySql/.docker/docker-compose.yml | 2 ++ Api.Data.PostgreSQL.Spatial/.docker/docker-compose.dcproj | 1 + Api.Data.PostgreSQL.Spatial/.docker/docker-compose.yml | 2 ++ Api.Data.PostgreSQL/.docker/docker-compose.dcproj | 1 + Api.Data.PostgreSQL/.docker/docker-compose.yml | 2 ++ Api.Data.Repository.AutoSave/.docker/docker-compose.dcproj | 1 + Api.Data.Repository.AutoSave/.docker/docker-compose.yml | 2 ++ Api.Data.Repository.Includes/.docker/docker-compose.dcproj | 1 + Api.Data.Repository.Includes/.docker/docker-compose.yml | 2 ++ Api.Data.SoftDelete/.docker/docker-compose.dcproj | 1 + Api.Data.SoftDelete/.docker/docker-compose.yml | 2 ++ Api.Data.SqLite/.docker/docker-compose.dcproj | 1 + Api.Data.SqLite/.docker/docker-compose.yml | 2 ++ Api.Data.SqlServer.Spatial/.docker/docker-compose.dcproj | 1 + Api.Data.SqlServer.Spatial/.docker/docker-compose.yml | 2 ++ Api.Data.SqlServer/.docker/docker-compose.dcproj | 1 + Api.Data.SqlServer/.docker/docker-compose.yml | 2 ++ Api.Data.Triggers/.docker/docker-compose.dcproj | 1 + Api.Data.Triggers/.docker/docker-compose.yml | 2 ++ Api.Documentation.Csp/.docker/docker-compose.dcproj | 1 + Api.Documentation.Csp/.docker/docker-compose.yml | 2 ++ Api.Documentation/.docker/docker-compose.dcproj | 1 + Api.Documentation/.docker/docker-compose.yml | 2 ++ Api.ErrorHandling/.docker/docker-compose.dcproj | 1 + Api.ErrorHandling/.docker/docker-compose.yml | 2 ++ Api.Eventing.RabbitMq/.docker/docker-compose.dcproj | 1 + Api.Eventing.RabbitMq/.docker/docker-compose.yml | 2 ++ Api.HealthChecks/.docker/docker-compose.dcproj | 1 + Api.HealthChecks/.docker/docker-compose.yml | 2 ++ Api.Hosting.Http/.docker/docker-compose.dcproj | 1 + Api.Hosting.Http/.docker/docker-compose.yml | 2 ++ Api.Hosting.Https/.docker/docker-compose.dcproj | 1 + Api.Hosting.Https/.docker/docker-compose.yml | 2 ++ Api.Hosting.MultipartLimits/.docker/docker-compose.dcproj | 1 + Api.Hosting.MultipartLimits/.docker/docker-compose.yml | 2 ++ Api.Localization/.docker/docker-compose.dcproj | 1 + Api.Localization/.docker/docker-compose.yml | 2 ++ Api.Logging.Log4Net/.docker/docker-compose.dcproj | 1 + Api.Logging.Log4Net/.docker/docker-compose.yml | 2 ++ Api.Logging.Microsoft/.docker/docker-compose.dcproj | 1 + Api.Logging.Microsoft/.docker/docker-compose.yml | 2 ++ Api.Logging.NLog/.docker/docker-compose.dcproj | 1 + Api.Logging.NLog/.docker/docker-compose.yml | 2 ++ Api.Logging.Serilog/.docker/docker-compose.dcproj | 1 + Api.Logging.Serilog/.docker/docker-compose.yml | 2 ++ Api.Metrics/.docker/docker-compose.dcproj | 1 + Api.Metrics/.docker/docker-compose.yml | 2 ++ Api.MultipartJson/.docker/docker-compose.dcproj | 1 + Api.MultipartJson/.docker/docker-compose.yml | 2 ++ .../.docker/docker-compose.dcproj | 1 + .../.docker/docker-compose.yml | 2 ++ .../.docker/docker-compose.dcproj | 1 + Api.PolicyHeaders.ContentTypeOptions/.docker/docker-compose.yml | 2 ++ Api.PolicyHeaders.Cors/.docker/docker-compose.dcproj | 1 + Api.PolicyHeaders.Cors/.docker/docker-compose.yml | 2 ++ .../.docker/docker-compose.dcproj | 1 + Api.PolicyHeaders.ForwardedHeaders/.docker/docker-compose.yml | 2 ++ Api.PolicyHeaders.FrameOptions/.docker/docker-compose.dcproj | 1 + Api.PolicyHeaders.FrameOptions/.docker/docker-compose.yml | 2 ++ Api.PolicyHeaders.Hsts/.docker/docker-compose.dcproj | 1 + Api.PolicyHeaders.Hsts/.docker/docker-compose.yml | 2 ++ Api.PolicyHeaders.ReferrerPolicy/.docker/docker-compose.dcproj | 1 + Api.PolicyHeaders.ReferrerPolicy/.docker/docker-compose.yml | 2 ++ Api.PolicyHeaders.Robots/.docker/docker-compose.dcproj | 1 + Api.PolicyHeaders.Robots/.docker/docker-compose.yml | 2 ++ Api.PolicyHeaders.XssProtection/.docker/docker-compose.dcproj | 1 + Api.PolicyHeaders.XssProtection/.docker/docker-compose.yml | 2 ++ Api.RequestTracing/.docker/docker-compose.dcproj | 1 + Api.RequestTracing/.docker/docker-compose.yml | 2 ++ Api.ResponseCache/.docker/docker-compose.dcproj | 1 + Api.ResponseCache/.docker/docker-compose.yml | 2 ++ Api.ResponseCompression/.docker/docker-compose.dcproj | 1 + Api.ResponseCompression/.docker/docker-compose.yml | 2 ++ Api.Session/.docker/docker-compose.dcproj | 1 + Api.Session/.docker/docker-compose.yml | 2 ++ Api.StartupTasks/.docker/docker-compose.dcproj | 1 + Api.StartupTasks/.docker/docker-compose.yml | 2 ++ Api.StaticFiles/.docker/docker-compose.dcproj | 1 + Api.StaticFiles/.docker/docker-compose.yml | 2 ++ Api.Storage.Azure/.docker/docker-compose.dcproj | 1 + Api.Storage.Azure/.docker/docker-compose.yml | 2 ++ Api.Storage.Local/.docker/docker-compose.dcproj | 1 + Api.Storage.Local/.docker/docker-compose.yml | 2 ++ Api.TimeZone/.docker/docker-compose.dcproj | 1 + Api.TimeZone/.docker/docker-compose.yml | 2 ++ Api.Versioning/.docker/docker-compose.dcproj | 1 + Api.Versioning/.docker/docker-compose.yml | 2 ++ Api.VirusScan/.docker/docker-compose.dcproj | 1 + Api.VirusScan/.docker/docker-compose.yml | 2 ++ Api._Blank/.docker/docker-compose.dcproj | 1 + Api._Blank/.docker/docker-compose.yml | 2 ++ Console.CustomConfigSection/.docker/docker-compose.dcproj | 1 + Console.CustomConfigSection/.docker/docker-compose.yml | 2 ++ Console.CustomService/.docker/docker-compose.dcproj | 1 + Console.CustomService/.docker/docker-compose.yml | 2 ++ Console.Data.InMemory/.docker/docker-compose.dcproj | 1 + Console.Data.InMemory/.docker/docker-compose.yml | 2 ++ Console.Data.MySql/.docker/docker-compose.dcproj | 1 + Console.Data.MySql/.docker/docker-compose.yml | 2 ++ Console.Data.PostgreSQL/.docker/docker-compose.dcproj | 1 + Console.Data.PostgreSQL/.docker/docker-compose.yml | 2 ++ Console.Data.SqLite/.docker/docker-compose.dcproj | 1 + Console.Data.SqLite/.docker/docker-compose.yml | 2 ++ Console.Data.SqlServer/.docker/docker-compose.dcproj | 1 + Console.Data.SqlServer/.docker/docker-compose.yml | 2 ++ Console.Eventing.RabbitMq/.docker/docker-compose.dcproj | 1 + Console.Eventing.RabbitMq/.docker/docker-compose.yml | 2 ++ Console.ExceptionHandling/.docker/docker-compose.dcproj | 1 + Console.ExceptionHandling/.docker/docker-compose.yml | 2 ++ Console.Localization/.docker/docker-compose.dcproj | 1 + Console.Localization/.docker/docker-compose.yml | 2 ++ Console.Logging.Log4Net/.docker/docker-compose.dcproj | 1 + Console.Logging.Log4Net/.docker/docker-compose.yml | 2 ++ Console.Logging.Microsoft/.docker/docker-compose.dcproj | 1 + Console.Logging.Microsoft/.docker/docker-compose.yml | 2 ++ Console.Logging.NLog/.docker/docker-compose.dcproj | 1 + Console.Logging.NLog/.docker/docker-compose.yml | 2 ++ Console.Logging.Serilog/.docker/docker-compose.dcproj | 1 + Console.Logging.Serilog/.docker/docker-compose.yml | 2 ++ Console.StartupTasks/.docker/docker-compose.dcproj | 1 + Console.StartupTasks/.docker/docker-compose.yml | 2 ++ Console.Storage.Azure/.docker/docker-compose.dcproj | 1 + Console.Storage.Azure/.docker/docker-compose.yml | 2 ++ Console.Storage.Local/.docker/docker-compose.dcproj | 1 + Console.Storage.Local/.docker/docker-compose.yml | 2 ++ Console.Workers/.docker/docker-compose.dcproj | 1 + Console.Workers/.docker/docker-compose.yml | 2 ++ Console._Blank/.docker/docker-compose.dcproj | 1 + Console._Blank/.docker/docker-compose.yml | 2 ++ Web._Blank/.docker/docker-compose.dcproj | 1 + Web._Blank/.docker/docker-compose.yml | 2 ++ 184 files changed, 276 insertions(+) diff --git a/Api.ApiClients.Audit/.docker/docker-compose.dcproj b/Api.ApiClients.Audit/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Api.ApiClients.Audit/.docker/docker-compose.dcproj +++ b/Api.ApiClients.Audit/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Api.ApiClients.Audit/.docker/docker-compose.yml b/Api.ApiClients.Audit/.docker/docker-compose.yml index 65f425e7..7342312b 100644 --- a/Api.ApiClients.Audit/.docker/docker-compose.yml +++ b/Api.ApiClients.Audit/.docker/docker-compose.yml @@ -8,6 +8,8 @@ services: build: context: ../Api.ApiClients.Audit dockerfile: "Dockerfile.Local" + env_file: + - .env networks: - network diff --git a/Api.ApiClients.Entity/.docker/docker-compose.dcproj b/Api.ApiClients.Entity/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Api.ApiClients.Entity/.docker/docker-compose.dcproj +++ b/Api.ApiClients.Entity/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Api.ApiClients.Entity/.docker/docker-compose.yml b/Api.ApiClients.Entity/.docker/docker-compose.yml index a51b8975..0b7f7529 100644 --- a/Api.ApiClients.Entity/.docker/docker-compose.yml +++ b/Api.ApiClients.Entity/.docker/docker-compose.yml @@ -8,6 +8,8 @@ services: build: context: ../Api.ApiClients.Entity dockerfile: "Dockerfile.Local" + env_file: + - .env networks: - network diff --git a/Api.ApiClients.RootLogIn/.docker/docker-compose.dcproj b/Api.ApiClients.RootLogIn/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Api.ApiClients.RootLogIn/.docker/docker-compose.dcproj +++ b/Api.ApiClients.RootLogIn/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Api.ApiClients.RootLogIn/.docker/docker-compose.yml b/Api.ApiClients.RootLogIn/.docker/docker-compose.yml index accdcd21..1385a4e9 100644 --- a/Api.ApiClients.RootLogIn/.docker/docker-compose.yml +++ b/Api.ApiClients.RootLogIn/.docker/docker-compose.yml @@ -8,6 +8,8 @@ services: build: context: ../Api.ApiClients.RootLogIn dockerfile: "Dockerfile.Local" + env_file: + - .env networks: - network diff --git a/Api.ApiClients/.docker/docker-compose.dcproj b/Api.ApiClients/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Api.ApiClients/.docker/docker-compose.dcproj +++ b/Api.ApiClients/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Api.ApiClients/.docker/docker-compose.yml b/Api.ApiClients/.docker/docker-compose.yml index ab197d18..9971f481 100644 --- a/Api.ApiClients/.docker/docker-compose.yml +++ b/Api.ApiClients/.docker/docker-compose.yml @@ -8,6 +8,8 @@ services: build: context: ../Api.ApiClients dockerfile: "Dockerfile.Local" + env_file: + - .env networks: - network diff --git a/Api.Auth.External.Custom/.docker/docker-compose.dcproj b/Api.Auth.External.Custom/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Api.Auth.External.Custom/.docker/docker-compose.dcproj +++ b/Api.Auth.External.Custom/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Api.Auth.External.Custom/.docker/docker-compose.yml b/Api.Auth.External.Custom/.docker/docker-compose.yml index c81609f9..2e63ed5f 100644 --- a/Api.Auth.External.Custom/.docker/docker-compose.yml +++ b/Api.Auth.External.Custom/.docker/docker-compose.yml @@ -8,6 +8,8 @@ services: build: context: ../Api.Auth.External.Custom dockerfile: "Dockerfile.Local" + env_file: + - .env networks: - network diff --git a/Api.Auth.External.Microsoft/.docker/docker-compose.dcproj b/Api.Auth.External.Microsoft/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Api.Auth.External.Microsoft/.docker/docker-compose.dcproj +++ b/Api.Auth.External.Microsoft/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Api.Auth.External.Microsoft/.docker/docker-compose.yml b/Api.Auth.External.Microsoft/.docker/docker-compose.yml index 02b31ef3..b44373fb 100644 --- a/Api.Auth.External.Microsoft/.docker/docker-compose.yml +++ b/Api.Auth.External.Microsoft/.docker/docker-compose.yml @@ -8,6 +8,8 @@ services: build: context: ../Api.Auth.External.Microsoft dockerfile: "Dockerfile.Local" + env_file: + - .env networks: - network diff --git a/Api.Auth.RootLogin/.docker/docker-compose.dcproj b/Api.Auth.RootLogin/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Api.Auth.RootLogin/.docker/docker-compose.dcproj +++ b/Api.Auth.RootLogin/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Api.Auth.RootLogin/.docker/docker-compose.yml b/Api.Auth.RootLogin/.docker/docker-compose.yml index 312e415c..e117a5c8 100644 --- a/Api.Auth.RootLogin/.docker/docker-compose.yml +++ b/Api.Auth.RootLogin/.docker/docker-compose.yml @@ -8,6 +8,8 @@ services: build: context: ../Api.Auth.RootLogin dockerfile: "Dockerfile.Local" + env_file: + - .env networks: - network diff --git a/Api.Authorization/.docker/docker-compose.dcproj b/Api.Authorization/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Api.Authorization/.docker/docker-compose.dcproj +++ b/Api.Authorization/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Api.Authorization/.docker/docker-compose.yml b/Api.Authorization/.docker/docker-compose.yml index b0511a39..26da2041 100644 --- a/Api.Authorization/.docker/docker-compose.yml +++ b/Api.Authorization/.docker/docker-compose.yml @@ -8,6 +8,8 @@ services: build: context: ../Api.Authorization dockerfile: "Dockerfile.Local" + env_file: + - .env networks: - network diff --git a/Api.ContentNegotiation/.docker/docker-compose.dcproj b/Api.ContentNegotiation/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Api.ContentNegotiation/.docker/docker-compose.dcproj +++ b/Api.ContentNegotiation/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Api.ContentNegotiation/.docker/docker-compose.yml b/Api.ContentNegotiation/.docker/docker-compose.yml index 31e6a9a3..a2377bfd 100644 --- a/Api.ContentNegotiation/.docker/docker-compose.yml +++ b/Api.ContentNegotiation/.docker/docker-compose.yml @@ -8,6 +8,8 @@ services: build: context: ../Api.ContentNegotiation dockerfile: "Dockerfile.Local" + env_file: + - .env networks: - network diff --git a/Api.Cookies/.docker/docker-compose.dcproj b/Api.Cookies/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Api.Cookies/.docker/docker-compose.dcproj +++ b/Api.Cookies/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Api.Cookies/.docker/docker-compose.yml b/Api.Cookies/.docker/docker-compose.yml index 2ad288b3..f61f1d08 100644 --- a/Api.Cookies/.docker/docker-compose.yml +++ b/Api.Cookies/.docker/docker-compose.yml @@ -8,6 +8,8 @@ services: build: context: ../Api.Cookies dockerfile: "Dockerfile.Local" + env_file: + - .env networks: - network diff --git a/Api.CustomConfigSection/.docker/docker-compose.dcproj b/Api.CustomConfigSection/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Api.CustomConfigSection/.docker/docker-compose.dcproj +++ b/Api.CustomConfigSection/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Api.CustomConfigSection/.docker/docker-compose.yml b/Api.CustomConfigSection/.docker/docker-compose.yml index 9c4a6198..feb56a6d 100644 --- a/Api.CustomConfigSection/.docker/docker-compose.yml +++ b/Api.CustomConfigSection/.docker/docker-compose.yml @@ -8,6 +8,8 @@ services: build: context: ../Api.CustomConfigSection dockerfile: "Dockerfile.Local" + env_file: + - .env networks: - network diff --git a/Api.CustomMiddleware/.docker/docker-compose.dcproj b/Api.CustomMiddleware/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Api.CustomMiddleware/.docker/docker-compose.dcproj +++ b/Api.CustomMiddleware/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Api.CustomMiddleware/.docker/docker-compose.yml b/Api.CustomMiddleware/.docker/docker-compose.yml index 21e01392..b06fbaea 100644 --- a/Api.CustomMiddleware/.docker/docker-compose.yml +++ b/Api.CustomMiddleware/.docker/docker-compose.yml @@ -8,6 +8,8 @@ services: build: context: ../Api.CustomMiddleware dockerfile: "Dockerfile.Local" + env_file: + - .env networks: - network diff --git a/Api.CustomService/.docker/docker-compose.dcproj b/Api.CustomService/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Api.CustomService/.docker/docker-compose.dcproj +++ b/Api.CustomService/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Api.CustomService/.docker/docker-compose.yml b/Api.CustomService/.docker/docker-compose.yml index 2b5bedb8..6c7a82eb 100644 --- a/Api.CustomService/.docker/docker-compose.yml +++ b/Api.CustomService/.docker/docker-compose.yml @@ -8,6 +8,8 @@ services: build: context: ../Api.CustomService dockerfile: "Dockerfile.Local" + env_file: + - .env networks: - network diff --git a/Api.Data.Audit/.docker/docker-compose.dcproj b/Api.Data.Audit/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Api.Data.Audit/.docker/docker-compose.dcproj +++ b/Api.Data.Audit/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Api.Data.Audit/.docker/docker-compose.yml b/Api.Data.Audit/.docker/docker-compose.yml index 0b053f4a..3f5236b8 100644 --- a/Api.Data.Audit/.docker/docker-compose.yml +++ b/Api.Data.Audit/.docker/docker-compose.yml @@ -8,6 +8,8 @@ services: build: context: ../Api.Data.Audit dockerfile: "Dockerfile.Local" + env_file: + - .env depends_on: - database networks: diff --git a/Api.Data.EntityEvents/.docker/docker-compose.dcproj b/Api.Data.EntityEvents/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Api.Data.EntityEvents/.docker/docker-compose.dcproj +++ b/Api.Data.EntityEvents/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Api.Data.EntityEvents/.docker/docker-compose.yml b/Api.Data.EntityEvents/.docker/docker-compose.yml index e0ad465e..d97286b9 100644 --- a/Api.Data.EntityEvents/.docker/docker-compose.yml +++ b/Api.Data.EntityEvents/.docker/docker-compose.yml @@ -8,6 +8,8 @@ services: build: context: ../Api.Data.EntityEvents dockerfile: "Dockerfile.Local" + env_file: + - .env depends_on: - database - eventing diff --git a/Api.Data.Identity.Auth.ApiKey/.docker/docker-compose.dcproj b/Api.Data.Identity.Auth.ApiKey/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Api.Data.Identity.Auth.ApiKey/.docker/docker-compose.dcproj +++ b/Api.Data.Identity.Auth.ApiKey/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Api.Data.Identity.Auth.ApiKey/.docker/docker-compose.yml b/Api.Data.Identity.Auth.ApiKey/.docker/docker-compose.yml index e8a95380..8b61c368 100644 --- a/Api.Data.Identity.Auth.ApiKey/.docker/docker-compose.yml +++ b/Api.Data.Identity.Auth.ApiKey/.docker/docker-compose.yml @@ -8,6 +8,8 @@ services: build: context: ../Api.Data.Identity.Auth.ApiKey dockerfile: "Dockerfile.Local" + env_file: + - .env depends_on: - database networks: diff --git a/Api.Data.Identity.Auth.External.Custom/.docker/docker-compose.dcproj b/Api.Data.Identity.Auth.External.Custom/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Api.Data.Identity.Auth.External.Custom/.docker/docker-compose.dcproj +++ b/Api.Data.Identity.Auth.External.Custom/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Api.Data.Identity.Auth.External.Custom/.docker/docker-compose.yml b/Api.Data.Identity.Auth.External.Custom/.docker/docker-compose.yml index 77810132..0927f2b4 100644 --- a/Api.Data.Identity.Auth.External.Custom/.docker/docker-compose.yml +++ b/Api.Data.Identity.Auth.External.Custom/.docker/docker-compose.yml @@ -8,6 +8,8 @@ services: build: context: ../Api.Data.Identity.Auth.External.Custom dockerfile: "Dockerfile.Local" + env_file: + - .env depends_on: - database networks: diff --git a/Api.Data.Identity.Auth.Jwt/.docker/docker-compose.dcproj b/Api.Data.Identity.Auth.Jwt/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Api.Data.Identity.Auth.Jwt/.docker/docker-compose.dcproj +++ b/Api.Data.Identity.Auth.Jwt/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Api.Data.Identity.Auth.Jwt/.docker/docker-compose.yml b/Api.Data.Identity.Auth.Jwt/.docker/docker-compose.yml index 599c4f0c..e365a537 100644 --- a/Api.Data.Identity.Auth.Jwt/.docker/docker-compose.yml +++ b/Api.Data.Identity.Auth.Jwt/.docker/docker-compose.yml @@ -8,6 +8,8 @@ services: build: context: ../Api.Data.Identity.Auth.Jwt dockerfile: "Dockerfile.Local" + env_file: + - .env depends_on: - database networks: diff --git a/Api.Data.Identity/.docker/docker-compose.dcproj b/Api.Data.Identity/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Api.Data.Identity/.docker/docker-compose.dcproj +++ b/Api.Data.Identity/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Api.Data.Identity/.docker/docker-compose.yml b/Api.Data.Identity/.docker/docker-compose.yml index 6b6b16db..9c225812 100644 --- a/Api.Data.Identity/.docker/docker-compose.yml +++ b/Api.Data.Identity/.docker/docker-compose.yml @@ -8,6 +8,8 @@ services: build: context: ../Api.Data.Identity dockerfile: "Dockerfile.Local" + env_file: + - .env depends_on: - database networks: diff --git a/Api.Data.InMemory/.docker/docker-compose.dcproj b/Api.Data.InMemory/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Api.Data.InMemory/.docker/docker-compose.dcproj +++ b/Api.Data.InMemory/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Api.Data.InMemory/.docker/docker-compose.yml b/Api.Data.InMemory/.docker/docker-compose.yml index 6a0d4084..65593e0a 100644 --- a/Api.Data.InMemory/.docker/docker-compose.yml +++ b/Api.Data.InMemory/.docker/docker-compose.yml @@ -8,6 +8,8 @@ services: build: context: ../Api.Data.InMemory dockerfile: "Dockerfile.Local" + env_file: + - .env networks: - network diff --git a/Api.Data.LazyLoading/.docker/docker-compose.dcproj b/Api.Data.LazyLoading/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Api.Data.LazyLoading/.docker/docker-compose.dcproj +++ b/Api.Data.LazyLoading/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Api.Data.LazyLoading/.docker/docker-compose.yml b/Api.Data.LazyLoading/.docker/docker-compose.yml index 986f0034..cf317284 100644 --- a/Api.Data.LazyLoading/.docker/docker-compose.yml +++ b/Api.Data.LazyLoading/.docker/docker-compose.yml @@ -8,6 +8,8 @@ services: build: context: ../Api.Data.LazyLoading dockerfile: "Dockerfile.Local" + env_file: + - .env depends_on: - database networks: diff --git a/Api.Data.MySql.Collation/.docker/docker-compose.dcproj b/Api.Data.MySql.Collation/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Api.Data.MySql.Collation/.docker/docker-compose.dcproj +++ b/Api.Data.MySql.Collation/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Api.Data.MySql.Collation/.docker/docker-compose.yml b/Api.Data.MySql.Collation/.docker/docker-compose.yml index 6b453c40..aaafefa2 100644 --- a/Api.Data.MySql.Collation/.docker/docker-compose.yml +++ b/Api.Data.MySql.Collation/.docker/docker-compose.yml @@ -8,6 +8,8 @@ services: build: context: ../Api.Data.MySql.Collation dockerfile: "Dockerfile.Local" + env_file: + - .env depends_on: - database networks: diff --git a/Api.Data.MySql.Mappings/.docker/docker-compose.dcproj b/Api.Data.MySql.Mappings/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Api.Data.MySql.Mappings/.docker/docker-compose.dcproj +++ b/Api.Data.MySql.Mappings/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Api.Data.MySql.Mappings/.docker/docker-compose.yml b/Api.Data.MySql.Mappings/.docker/docker-compose.yml index dfecb5b3..2e1cef9d 100644 --- a/Api.Data.MySql.Mappings/.docker/docker-compose.yml +++ b/Api.Data.MySql.Mappings/.docker/docker-compose.yml @@ -8,6 +8,8 @@ services: build: context: ../Api.Data.MySql.Mappings dockerfile: "Dockerfile.Local" + env_file: + - .env depends_on: - database networks: diff --git a/Api.Data.MySql.Spatial/.docker/docker-compose.dcproj b/Api.Data.MySql.Spatial/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Api.Data.MySql.Spatial/.docker/docker-compose.dcproj +++ b/Api.Data.MySql.Spatial/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Api.Data.MySql.Spatial/.docker/docker-compose.yml b/Api.Data.MySql.Spatial/.docker/docker-compose.yml index 7c9e8d48..ab33c604 100644 --- a/Api.Data.MySql.Spatial/.docker/docker-compose.yml +++ b/Api.Data.MySql.Spatial/.docker/docker-compose.yml @@ -8,6 +8,8 @@ services: build: context: ../Api.Data.MySql.Spatial dockerfile: "Dockerfile.Local" + env_file: + - .env depends_on: - database networks: diff --git a/Api.Data.MySql.StoredProcedures/.docker/docker-compose.dcproj b/Api.Data.MySql.StoredProcedures/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Api.Data.MySql.StoredProcedures/.docker/docker-compose.dcproj +++ b/Api.Data.MySql.StoredProcedures/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Api.Data.MySql.StoredProcedures/.docker/docker-compose.yml b/Api.Data.MySql.StoredProcedures/.docker/docker-compose.yml index 58e90a83..bf6afbae 100644 --- a/Api.Data.MySql.StoredProcedures/.docker/docker-compose.yml +++ b/Api.Data.MySql.StoredProcedures/.docker/docker-compose.yml @@ -8,6 +8,8 @@ services: build: context: ../Api.Data.MySql.StoredProcedures dockerfile: "Dockerfile.Local" + env_file: + - .env depends_on: - database networks: diff --git a/Api.Data.MySql.Views/.docker/docker-compose.dcproj b/Api.Data.MySql.Views/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Api.Data.MySql.Views/.docker/docker-compose.dcproj +++ b/Api.Data.MySql.Views/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Api.Data.MySql.Views/.docker/docker-compose.yml b/Api.Data.MySql.Views/.docker/docker-compose.yml index 4f52ca85..51c41f16 100644 --- a/Api.Data.MySql.Views/.docker/docker-compose.yml +++ b/Api.Data.MySql.Views/.docker/docker-compose.yml @@ -8,6 +8,8 @@ services: build: context: ../Api.Data.MySql.Views dockerfile: "Dockerfile.Local" + env_file: + - .env depends_on: - database networks: diff --git a/Api.Data.MySql/.docker/docker-compose.dcproj b/Api.Data.MySql/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Api.Data.MySql/.docker/docker-compose.dcproj +++ b/Api.Data.MySql/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Api.Data.MySql/.docker/docker-compose.yml b/Api.Data.MySql/.docker/docker-compose.yml index 05a59213..30f24205 100644 --- a/Api.Data.MySql/.docker/docker-compose.yml +++ b/Api.Data.MySql/.docker/docker-compose.yml @@ -8,6 +8,8 @@ services: build: context: ../Api.Data.MySql dockerfile: "Dockerfile.Local" + env_file: + - .env depends_on: - database networks: diff --git a/Api.Data.PostgreSQL.Spatial/.docker/docker-compose.dcproj b/Api.Data.PostgreSQL.Spatial/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Api.Data.PostgreSQL.Spatial/.docker/docker-compose.dcproj +++ b/Api.Data.PostgreSQL.Spatial/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Api.Data.PostgreSQL.Spatial/.docker/docker-compose.yml b/Api.Data.PostgreSQL.Spatial/.docker/docker-compose.yml index 80392323..1768fc50 100644 --- a/Api.Data.PostgreSQL.Spatial/.docker/docker-compose.yml +++ b/Api.Data.PostgreSQL.Spatial/.docker/docker-compose.yml @@ -8,6 +8,8 @@ services: build: context: ../Api.Data.PostgreSQL.Spatial dockerfile: "Dockerfile.Local" + env_file: + - .env depends_on: - database networks: diff --git a/Api.Data.PostgreSQL/.docker/docker-compose.dcproj b/Api.Data.PostgreSQL/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Api.Data.PostgreSQL/.docker/docker-compose.dcproj +++ b/Api.Data.PostgreSQL/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Api.Data.PostgreSQL/.docker/docker-compose.yml b/Api.Data.PostgreSQL/.docker/docker-compose.yml index 175643d3..3480a16b 100644 --- a/Api.Data.PostgreSQL/.docker/docker-compose.yml +++ b/Api.Data.PostgreSQL/.docker/docker-compose.yml @@ -8,6 +8,8 @@ services: build: context: ../Api.Data.PostgreSQL dockerfile: "Dockerfile.Local" + env_file: + - .env depends_on: - database networks: diff --git a/Api.Data.Repository.AutoSave/.docker/docker-compose.dcproj b/Api.Data.Repository.AutoSave/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Api.Data.Repository.AutoSave/.docker/docker-compose.dcproj +++ b/Api.Data.Repository.AutoSave/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Api.Data.Repository.AutoSave/.docker/docker-compose.yml b/Api.Data.Repository.AutoSave/.docker/docker-compose.yml index 3b06ee7b..87450e4a 100644 --- a/Api.Data.Repository.AutoSave/.docker/docker-compose.yml +++ b/Api.Data.Repository.AutoSave/.docker/docker-compose.yml @@ -8,6 +8,8 @@ services: build: context: ../Api.Data.Repository.AutoSave dockerfile: "Dockerfile.Local" + env_file: + - .env depends_on: - database networks: diff --git a/Api.Data.Repository.Includes/.docker/docker-compose.dcproj b/Api.Data.Repository.Includes/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Api.Data.Repository.Includes/.docker/docker-compose.dcproj +++ b/Api.Data.Repository.Includes/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Api.Data.Repository.Includes/.docker/docker-compose.yml b/Api.Data.Repository.Includes/.docker/docker-compose.yml index bb0a018c..dec92cee 100644 --- a/Api.Data.Repository.Includes/.docker/docker-compose.yml +++ b/Api.Data.Repository.Includes/.docker/docker-compose.yml @@ -8,6 +8,8 @@ services: build: context: ../Api.Data.Repository.Includes dockerfile: "Dockerfile.Local" + env_file: + - .env depends_on: - database networks: diff --git a/Api.Data.SoftDelete/.docker/docker-compose.dcproj b/Api.Data.SoftDelete/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Api.Data.SoftDelete/.docker/docker-compose.dcproj +++ b/Api.Data.SoftDelete/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Api.Data.SoftDelete/.docker/docker-compose.yml b/Api.Data.SoftDelete/.docker/docker-compose.yml index bf83a3c8..1ceed5ed 100644 --- a/Api.Data.SoftDelete/.docker/docker-compose.yml +++ b/Api.Data.SoftDelete/.docker/docker-compose.yml @@ -8,6 +8,8 @@ services: build: context: ../Api.Data.SoftDelete dockerfile: "Dockerfile.Local" + env_file: + - .env depends_on: - database networks: diff --git a/Api.Data.SqLite/.docker/docker-compose.dcproj b/Api.Data.SqLite/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Api.Data.SqLite/.docker/docker-compose.dcproj +++ b/Api.Data.SqLite/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Api.Data.SqLite/.docker/docker-compose.yml b/Api.Data.SqLite/.docker/docker-compose.yml index 28072671..78b0748a 100644 --- a/Api.Data.SqLite/.docker/docker-compose.yml +++ b/Api.Data.SqLite/.docker/docker-compose.yml @@ -5,6 +5,8 @@ services: build: context: ../Api.Data.SqLite dockerfile: Dockerfile.Local + env_file: + - .env restart: on-failure ports: - 8080:8080 diff --git a/Api.Data.SqlServer.Spatial/.docker/docker-compose.dcproj b/Api.Data.SqlServer.Spatial/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Api.Data.SqlServer.Spatial/.docker/docker-compose.dcproj +++ b/Api.Data.SqlServer.Spatial/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Api.Data.SqlServer.Spatial/.docker/docker-compose.yml b/Api.Data.SqlServer.Spatial/.docker/docker-compose.yml index 82e79e94..6ded6899 100644 --- a/Api.Data.SqlServer.Spatial/.docker/docker-compose.yml +++ b/Api.Data.SqlServer.Spatial/.docker/docker-compose.yml @@ -8,6 +8,8 @@ services: build: context: ../Api.Data.SqlServer.Spatial dockerfile: "Dockerfile.Local" + env_file: + - .env depends_on: - database networks: diff --git a/Api.Data.SqlServer/.docker/docker-compose.dcproj b/Api.Data.SqlServer/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Api.Data.SqlServer/.docker/docker-compose.dcproj +++ b/Api.Data.SqlServer/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Api.Data.SqlServer/.docker/docker-compose.yml b/Api.Data.SqlServer/.docker/docker-compose.yml index 33a18108..51ddb67a 100644 --- a/Api.Data.SqlServer/.docker/docker-compose.yml +++ b/Api.Data.SqlServer/.docker/docker-compose.yml @@ -8,6 +8,8 @@ services: build: context: ../Api.Data.SqlServer dockerfile: "Dockerfile.Local" + env_file: + - .env depends_on: - database networks: diff --git a/Api.Data.Triggers/.docker/docker-compose.dcproj b/Api.Data.Triggers/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Api.Data.Triggers/.docker/docker-compose.dcproj +++ b/Api.Data.Triggers/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Api.Data.Triggers/.docker/docker-compose.yml b/Api.Data.Triggers/.docker/docker-compose.yml index 3fd7950a..84e843b1 100644 --- a/Api.Data.Triggers/.docker/docker-compose.yml +++ b/Api.Data.Triggers/.docker/docker-compose.yml @@ -8,6 +8,8 @@ services: build: context: ../Api.Data.Triggers dockerfile: "Dockerfile.Local" + env_file: + - .env depends_on: - database networks: diff --git a/Api.Documentation.Csp/.docker/docker-compose.dcproj b/Api.Documentation.Csp/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Api.Documentation.Csp/.docker/docker-compose.dcproj +++ b/Api.Documentation.Csp/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Api.Documentation.Csp/.docker/docker-compose.yml b/Api.Documentation.Csp/.docker/docker-compose.yml index ec265b69..bc2123e9 100644 --- a/Api.Documentation.Csp/.docker/docker-compose.yml +++ b/Api.Documentation.Csp/.docker/docker-compose.yml @@ -9,6 +9,8 @@ services: build: context: ../Api.Documentation.Csp dockerfile: "Dockerfile.Local" + env_file: + - .env volumes: - ../:/root/.dotnet/https networks: diff --git a/Api.Documentation/.docker/docker-compose.dcproj b/Api.Documentation/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Api.Documentation/.docker/docker-compose.dcproj +++ b/Api.Documentation/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Api.Documentation/.docker/docker-compose.yml b/Api.Documentation/.docker/docker-compose.yml index 80173a06..8f84957e 100644 --- a/Api.Documentation/.docker/docker-compose.yml +++ b/Api.Documentation/.docker/docker-compose.yml @@ -8,6 +8,8 @@ services: build: context: ../Api.Documentation dockerfile: "Dockerfile.Local" + env_file: + - .env networks: - network diff --git a/Api.ErrorHandling/.docker/docker-compose.dcproj b/Api.ErrorHandling/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Api.ErrorHandling/.docker/docker-compose.dcproj +++ b/Api.ErrorHandling/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Api.ErrorHandling/.docker/docker-compose.yml b/Api.ErrorHandling/.docker/docker-compose.yml index 7a9a44dc..44c110d7 100644 --- a/Api.ErrorHandling/.docker/docker-compose.yml +++ b/Api.ErrorHandling/.docker/docker-compose.yml @@ -8,6 +8,8 @@ services: build: context: ../Api.ErrorHandling dockerfile: "Dockerfile.Local" + env_file: + - .env networks: - network diff --git a/Api.Eventing.RabbitMq/.docker/docker-compose.dcproj b/Api.Eventing.RabbitMq/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Api.Eventing.RabbitMq/.docker/docker-compose.dcproj +++ b/Api.Eventing.RabbitMq/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Api.Eventing.RabbitMq/.docker/docker-compose.yml b/Api.Eventing.RabbitMq/.docker/docker-compose.yml index de9772ea..88a01aef 100644 --- a/Api.Eventing.RabbitMq/.docker/docker-compose.yml +++ b/Api.Eventing.RabbitMq/.docker/docker-compose.yml @@ -8,6 +8,8 @@ services: build: context: ../Api.Eventing.RabbitMq dockerfile: "Dockerfile.Local" + env_file: + - .env depends_on: - eventing networks: diff --git a/Api.HealthChecks/.docker/docker-compose.dcproj b/Api.HealthChecks/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Api.HealthChecks/.docker/docker-compose.dcproj +++ b/Api.HealthChecks/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Api.HealthChecks/.docker/docker-compose.yml b/Api.HealthChecks/.docker/docker-compose.yml index 550a4254..56ed6ca9 100644 --- a/Api.HealthChecks/.docker/docker-compose.yml +++ b/Api.HealthChecks/.docker/docker-compose.yml @@ -9,6 +9,8 @@ services: build: context: ../Api.HealthChecks dockerfile: "Dockerfile.Local" + env_file: + - .env volumes: - ../:/root/.dotnet/https networks: diff --git a/Api.Hosting.Http/.docker/docker-compose.dcproj b/Api.Hosting.Http/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Api.Hosting.Http/.docker/docker-compose.dcproj +++ b/Api.Hosting.Http/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Api.Hosting.Http/.docker/docker-compose.yml b/Api.Hosting.Http/.docker/docker-compose.yml index 04d84d49..500a29ae 100644 --- a/Api.Hosting.Http/.docker/docker-compose.yml +++ b/Api.Hosting.Http/.docker/docker-compose.yml @@ -8,6 +8,8 @@ services: build: context: ../Api.Hosting.Http dockerfile: "Dockerfile.Local" + env_file: + - .env networks: - network diff --git a/Api.Hosting.Https/.docker/docker-compose.dcproj b/Api.Hosting.Https/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Api.Hosting.Https/.docker/docker-compose.dcproj +++ b/Api.Hosting.Https/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Api.Hosting.Https/.docker/docker-compose.yml b/Api.Hosting.Https/.docker/docker-compose.yml index 62a4f392..55366072 100644 --- a/Api.Hosting.Https/.docker/docker-compose.yml +++ b/Api.Hosting.Https/.docker/docker-compose.yml @@ -9,6 +9,8 @@ services: build: context: ../Api.Hosting.Https dockerfile: "Dockerfile.Local" + env_file: + - .env volumes: - ../:/root/.dotnet/https networks: diff --git a/Api.Hosting.MultipartLimits/.docker/docker-compose.dcproj b/Api.Hosting.MultipartLimits/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Api.Hosting.MultipartLimits/.docker/docker-compose.dcproj +++ b/Api.Hosting.MultipartLimits/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Api.Hosting.MultipartLimits/.docker/docker-compose.yml b/Api.Hosting.MultipartLimits/.docker/docker-compose.yml index 5fe0aef5..9c403e73 100644 --- a/Api.Hosting.MultipartLimits/.docker/docker-compose.yml +++ b/Api.Hosting.MultipartLimits/.docker/docker-compose.yml @@ -8,6 +8,8 @@ services: build: context: ../Api.Hosting.MultipartLimits dockerfile: "Dockerfile.Local" + env_file: + - .env networks: - network diff --git a/Api.Localization/.docker/docker-compose.dcproj b/Api.Localization/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Api.Localization/.docker/docker-compose.dcproj +++ b/Api.Localization/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Api.Localization/.docker/docker-compose.yml b/Api.Localization/.docker/docker-compose.yml index 0af53bf1..e9fc6419 100644 --- a/Api.Localization/.docker/docker-compose.yml +++ b/Api.Localization/.docker/docker-compose.yml @@ -8,6 +8,8 @@ services: build: context: ../Api.Localization dockerfile: "Dockerfile.Local" + env_file: + - .env networks: - network diff --git a/Api.Logging.Log4Net/.docker/docker-compose.dcproj b/Api.Logging.Log4Net/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Api.Logging.Log4Net/.docker/docker-compose.dcproj +++ b/Api.Logging.Log4Net/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Api.Logging.Log4Net/.docker/docker-compose.yml b/Api.Logging.Log4Net/.docker/docker-compose.yml index d0ceb01b..38521815 100644 --- a/Api.Logging.Log4Net/.docker/docker-compose.yml +++ b/Api.Logging.Log4Net/.docker/docker-compose.yml @@ -8,6 +8,8 @@ services: build: context: ../Api.Logging.Log4Net dockerfile: "Dockerfile.Local" + env_file: + - .env networks: - network diff --git a/Api.Logging.Microsoft/.docker/docker-compose.dcproj b/Api.Logging.Microsoft/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Api.Logging.Microsoft/.docker/docker-compose.dcproj +++ b/Api.Logging.Microsoft/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Api.Logging.Microsoft/.docker/docker-compose.yml b/Api.Logging.Microsoft/.docker/docker-compose.yml index be89bf7c..7c7796b8 100644 --- a/Api.Logging.Microsoft/.docker/docker-compose.yml +++ b/Api.Logging.Microsoft/.docker/docker-compose.yml @@ -8,6 +8,8 @@ services: build: context: ../Api.Logging.Microsoft dockerfile: "Dockerfile.Local" + env_file: + - .env networks: - network diff --git a/Api.Logging.NLog/.docker/docker-compose.dcproj b/Api.Logging.NLog/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Api.Logging.NLog/.docker/docker-compose.dcproj +++ b/Api.Logging.NLog/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Api.Logging.NLog/.docker/docker-compose.yml b/Api.Logging.NLog/.docker/docker-compose.yml index 0922a6b5..dfedd027 100644 --- a/Api.Logging.NLog/.docker/docker-compose.yml +++ b/Api.Logging.NLog/.docker/docker-compose.yml @@ -8,6 +8,8 @@ services: build: context: ../Api.Logging.NLog dockerfile: "Dockerfile.Local" + env_file: + - .env networks: - network diff --git a/Api.Logging.Serilog/.docker/docker-compose.dcproj b/Api.Logging.Serilog/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Api.Logging.Serilog/.docker/docker-compose.dcproj +++ b/Api.Logging.Serilog/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Api.Logging.Serilog/.docker/docker-compose.yml b/Api.Logging.Serilog/.docker/docker-compose.yml index c90a936c..686f6189 100644 --- a/Api.Logging.Serilog/.docker/docker-compose.yml +++ b/Api.Logging.Serilog/.docker/docker-compose.yml @@ -8,6 +8,8 @@ services: build: context: ../Api.Logging.Serilog dockerfile: "Dockerfile.Local" + env_file: + - .env networks: - network diff --git a/Api.Metrics/.docker/docker-compose.dcproj b/Api.Metrics/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Api.Metrics/.docker/docker-compose.dcproj +++ b/Api.Metrics/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Api.Metrics/.docker/docker-compose.yml b/Api.Metrics/.docker/docker-compose.yml index 97e5feda..ab1af6f1 100644 --- a/Api.Metrics/.docker/docker-compose.yml +++ b/Api.Metrics/.docker/docker-compose.yml @@ -8,6 +8,8 @@ services: build: context: ../Api.Metrics dockerfile: "Dockerfile.Local" + env_file: + - .env networks: - network diff --git a/Api.MultipartJson/.docker/docker-compose.dcproj b/Api.MultipartJson/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Api.MultipartJson/.docker/docker-compose.dcproj +++ b/Api.MultipartJson/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Api.MultipartJson/.docker/docker-compose.yml b/Api.MultipartJson/.docker/docker-compose.yml index c4c85d9e..75221ea7 100644 --- a/Api.MultipartJson/.docker/docker-compose.yml +++ b/Api.MultipartJson/.docker/docker-compose.yml @@ -8,6 +8,8 @@ services: build: context: ../Api.MultipartJson dockerfile: "Dockerfile.Local" + env_file: + - .env networks: - network diff --git a/Api.PolicyHeaders.ContentSecurityPolicy/.docker/docker-compose.dcproj b/Api.PolicyHeaders.ContentSecurityPolicy/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Api.PolicyHeaders.ContentSecurityPolicy/.docker/docker-compose.dcproj +++ b/Api.PolicyHeaders.ContentSecurityPolicy/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Api.PolicyHeaders.ContentSecurityPolicy/.docker/docker-compose.yml b/Api.PolicyHeaders.ContentSecurityPolicy/.docker/docker-compose.yml index 4d629227..97b7fe7a 100644 --- a/Api.PolicyHeaders.ContentSecurityPolicy/.docker/docker-compose.yml +++ b/Api.PolicyHeaders.ContentSecurityPolicy/.docker/docker-compose.yml @@ -9,6 +9,8 @@ services: build: context: ../Api.PolicyHeaders.ContentSecurityPolicy dockerfile: "Dockerfile.Local" + env_file: + - .env volumes: - ../:/root/.dotnet/https networks: diff --git a/Api.PolicyHeaders.ContentTypeOptions/.docker/docker-compose.dcproj b/Api.PolicyHeaders.ContentTypeOptions/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Api.PolicyHeaders.ContentTypeOptions/.docker/docker-compose.dcproj +++ b/Api.PolicyHeaders.ContentTypeOptions/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Api.PolicyHeaders.ContentTypeOptions/.docker/docker-compose.yml b/Api.PolicyHeaders.ContentTypeOptions/.docker/docker-compose.yml index a88b70bc..18a197ad 100644 --- a/Api.PolicyHeaders.ContentTypeOptions/.docker/docker-compose.yml +++ b/Api.PolicyHeaders.ContentTypeOptions/.docker/docker-compose.yml @@ -8,6 +8,8 @@ services: build: context: ../Api.PolicyHeaders.ContentTypeOptions dockerfile: "Dockerfile.Local" + env_file: + - .env networks: - network diff --git a/Api.PolicyHeaders.Cors/.docker/docker-compose.dcproj b/Api.PolicyHeaders.Cors/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Api.PolicyHeaders.Cors/.docker/docker-compose.dcproj +++ b/Api.PolicyHeaders.Cors/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Api.PolicyHeaders.Cors/.docker/docker-compose.yml b/Api.PolicyHeaders.Cors/.docker/docker-compose.yml index 78322a2c..25ce7a11 100644 --- a/Api.PolicyHeaders.Cors/.docker/docker-compose.yml +++ b/Api.PolicyHeaders.Cors/.docker/docker-compose.yml @@ -8,6 +8,8 @@ services: build: context: ../Api.PolicyHeaders.Cors dockerfile: "Dockerfile.Local" + env_file: + - .env networks: - network diff --git a/Api.PolicyHeaders.ForwardedHeaders/.docker/docker-compose.dcproj b/Api.PolicyHeaders.ForwardedHeaders/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Api.PolicyHeaders.ForwardedHeaders/.docker/docker-compose.dcproj +++ b/Api.PolicyHeaders.ForwardedHeaders/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Api.PolicyHeaders.ForwardedHeaders/.docker/docker-compose.yml b/Api.PolicyHeaders.ForwardedHeaders/.docker/docker-compose.yml index c0253a40..94a6a36e 100644 --- a/Api.PolicyHeaders.ForwardedHeaders/.docker/docker-compose.yml +++ b/Api.PolicyHeaders.ForwardedHeaders/.docker/docker-compose.yml @@ -8,6 +8,8 @@ services: build: context: ../Api.PolicyHeaders.ForwardedHeaders dockerfile: "Dockerfile.Local" + env_file: + - .env networks: - network diff --git a/Api.PolicyHeaders.FrameOptions/.docker/docker-compose.dcproj b/Api.PolicyHeaders.FrameOptions/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Api.PolicyHeaders.FrameOptions/.docker/docker-compose.dcproj +++ b/Api.PolicyHeaders.FrameOptions/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Api.PolicyHeaders.FrameOptions/.docker/docker-compose.yml b/Api.PolicyHeaders.FrameOptions/.docker/docker-compose.yml index 641fe30c..df67e29b 100644 --- a/Api.PolicyHeaders.FrameOptions/.docker/docker-compose.yml +++ b/Api.PolicyHeaders.FrameOptions/.docker/docker-compose.yml @@ -8,6 +8,8 @@ services: build: context: ../Api.PolicyHeaders.FrameOptions dockerfile: "Dockerfile.Local" + env_file: + - .env networks: - network diff --git a/Api.PolicyHeaders.Hsts/.docker/docker-compose.dcproj b/Api.PolicyHeaders.Hsts/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Api.PolicyHeaders.Hsts/.docker/docker-compose.dcproj +++ b/Api.PolicyHeaders.Hsts/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Api.PolicyHeaders.Hsts/.docker/docker-compose.yml b/Api.PolicyHeaders.Hsts/.docker/docker-compose.yml index 0c46f0dd..f5807f6c 100644 --- a/Api.PolicyHeaders.Hsts/.docker/docker-compose.yml +++ b/Api.PolicyHeaders.Hsts/.docker/docker-compose.yml @@ -9,6 +9,8 @@ services: build: context: ../Api.PolicyHeaders.Hsts dockerfile: "Dockerfile.Local" + env_file: + - .env volumes: - ../:/root/.dotnet/https networks: diff --git a/Api.PolicyHeaders.ReferrerPolicy/.docker/docker-compose.dcproj b/Api.PolicyHeaders.ReferrerPolicy/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Api.PolicyHeaders.ReferrerPolicy/.docker/docker-compose.dcproj +++ b/Api.PolicyHeaders.ReferrerPolicy/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Api.PolicyHeaders.ReferrerPolicy/.docker/docker-compose.yml b/Api.PolicyHeaders.ReferrerPolicy/.docker/docker-compose.yml index a3364eab..80059e8e 100644 --- a/Api.PolicyHeaders.ReferrerPolicy/.docker/docker-compose.yml +++ b/Api.PolicyHeaders.ReferrerPolicy/.docker/docker-compose.yml @@ -8,6 +8,8 @@ services: build: context: ../Api.PolicyHeaders.ReferrerPolicy dockerfile: "Dockerfile.Local" + env_file: + - .env networks: - network diff --git a/Api.PolicyHeaders.Robots/.docker/docker-compose.dcproj b/Api.PolicyHeaders.Robots/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Api.PolicyHeaders.Robots/.docker/docker-compose.dcproj +++ b/Api.PolicyHeaders.Robots/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Api.PolicyHeaders.Robots/.docker/docker-compose.yml b/Api.PolicyHeaders.Robots/.docker/docker-compose.yml index b45799e9..631271ce 100644 --- a/Api.PolicyHeaders.Robots/.docker/docker-compose.yml +++ b/Api.PolicyHeaders.Robots/.docker/docker-compose.yml @@ -8,6 +8,8 @@ services: build: context: ../Api.PolicyHeaders.Robots dockerfile: "Dockerfile.Local" + env_file: + - .env networks: - network diff --git a/Api.PolicyHeaders.XssProtection/.docker/docker-compose.dcproj b/Api.PolicyHeaders.XssProtection/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Api.PolicyHeaders.XssProtection/.docker/docker-compose.dcproj +++ b/Api.PolicyHeaders.XssProtection/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Api.PolicyHeaders.XssProtection/.docker/docker-compose.yml b/Api.PolicyHeaders.XssProtection/.docker/docker-compose.yml index 01f78aba..2a468340 100644 --- a/Api.PolicyHeaders.XssProtection/.docker/docker-compose.yml +++ b/Api.PolicyHeaders.XssProtection/.docker/docker-compose.yml @@ -8,6 +8,8 @@ services: build: context: ../Api.PolicyHeaders.XssProtection dockerfile: "Dockerfile.Local" + env_file: + - .env networks: - network diff --git a/Api.RequestTracing/.docker/docker-compose.dcproj b/Api.RequestTracing/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Api.RequestTracing/.docker/docker-compose.dcproj +++ b/Api.RequestTracing/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Api.RequestTracing/.docker/docker-compose.yml b/Api.RequestTracing/.docker/docker-compose.yml index a0cfd6ed..e870b3cb 100644 --- a/Api.RequestTracing/.docker/docker-compose.yml +++ b/Api.RequestTracing/.docker/docker-compose.yml @@ -8,6 +8,8 @@ services: build: context: ../Api.RequestTracing dockerfile: "Dockerfile.Local" + env_file: + - .env networks: - network diff --git a/Api.ResponseCache/.docker/docker-compose.dcproj b/Api.ResponseCache/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Api.ResponseCache/.docker/docker-compose.dcproj +++ b/Api.ResponseCache/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Api.ResponseCache/.docker/docker-compose.yml b/Api.ResponseCache/.docker/docker-compose.yml index 641f6e5b..b05b3d86 100644 --- a/Api.ResponseCache/.docker/docker-compose.yml +++ b/Api.ResponseCache/.docker/docker-compose.yml @@ -8,6 +8,8 @@ services: build: context: ../Api.ResponseCache dockerfile: "Dockerfile.Local" + env_file: + - .env networks: - network diff --git a/Api.ResponseCompression/.docker/docker-compose.dcproj b/Api.ResponseCompression/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Api.ResponseCompression/.docker/docker-compose.dcproj +++ b/Api.ResponseCompression/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Api.ResponseCompression/.docker/docker-compose.yml b/Api.ResponseCompression/.docker/docker-compose.yml index e88bc020..2e7cfe9e 100644 --- a/Api.ResponseCompression/.docker/docker-compose.yml +++ b/Api.ResponseCompression/.docker/docker-compose.yml @@ -8,6 +8,8 @@ services: build: context: ../Api.ResponseCompression dockerfile: "Dockerfile.Local" + env_file: + - .env networks: - network diff --git a/Api.Session/.docker/docker-compose.dcproj b/Api.Session/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Api.Session/.docker/docker-compose.dcproj +++ b/Api.Session/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Api.Session/.docker/docker-compose.yml b/Api.Session/.docker/docker-compose.yml index 3c71b4fb..426f73f8 100644 --- a/Api.Session/.docker/docker-compose.yml +++ b/Api.Session/.docker/docker-compose.yml @@ -8,6 +8,8 @@ services: build: context: ../Api.Session dockerfile: "Dockerfile.Local" + env_file: + - .env networks: - network diff --git a/Api.StartupTasks/.docker/docker-compose.dcproj b/Api.StartupTasks/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Api.StartupTasks/.docker/docker-compose.dcproj +++ b/Api.StartupTasks/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Api.StartupTasks/.docker/docker-compose.yml b/Api.StartupTasks/.docker/docker-compose.yml index 37494314..4802aee8 100644 --- a/Api.StartupTasks/.docker/docker-compose.yml +++ b/Api.StartupTasks/.docker/docker-compose.yml @@ -8,6 +8,8 @@ services: build: context: ../Api.StartupTasks dockerfile: "Dockerfile.Local" + env_file: + - .env networks: - network diff --git a/Api.StaticFiles/.docker/docker-compose.dcproj b/Api.StaticFiles/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Api.StaticFiles/.docker/docker-compose.dcproj +++ b/Api.StaticFiles/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Api.StaticFiles/.docker/docker-compose.yml b/Api.StaticFiles/.docker/docker-compose.yml index ceae04df..4631a422 100644 --- a/Api.StaticFiles/.docker/docker-compose.yml +++ b/Api.StaticFiles/.docker/docker-compose.yml @@ -8,6 +8,8 @@ services: build: context: ../Api.StaticFiles dockerfile: "Dockerfile.Local" + env_file: + - .env networks: - network diff --git a/Api.Storage.Azure/.docker/docker-compose.dcproj b/Api.Storage.Azure/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Api.Storage.Azure/.docker/docker-compose.dcproj +++ b/Api.Storage.Azure/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Api.Storage.Azure/.docker/docker-compose.yml b/Api.Storage.Azure/.docker/docker-compose.yml index 881edf06..02f47ef4 100644 --- a/Api.Storage.Azure/.docker/docker-compose.yml +++ b/Api.Storage.Azure/.docker/docker-compose.yml @@ -8,6 +8,8 @@ services: build: context: ../Api.Storage.Azure dockerfile: "Dockerfile.Local" + env_file: + - .env networks: - network volumes: diff --git a/Api.Storage.Local/.docker/docker-compose.dcproj b/Api.Storage.Local/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Api.Storage.Local/.docker/docker-compose.dcproj +++ b/Api.Storage.Local/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Api.Storage.Local/.docker/docker-compose.yml b/Api.Storage.Local/.docker/docker-compose.yml index c33c1940..66430d7e 100644 --- a/Api.Storage.Local/.docker/docker-compose.yml +++ b/Api.Storage.Local/.docker/docker-compose.yml @@ -8,6 +8,8 @@ services: build: context: ../Api.Storage.Local dockerfile: "Dockerfile.Local" + env_file: + - .env networks: - network volumes: diff --git a/Api.TimeZone/.docker/docker-compose.dcproj b/Api.TimeZone/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Api.TimeZone/.docker/docker-compose.dcproj +++ b/Api.TimeZone/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Api.TimeZone/.docker/docker-compose.yml b/Api.TimeZone/.docker/docker-compose.yml index f9debe0d..6c687daa 100644 --- a/Api.TimeZone/.docker/docker-compose.yml +++ b/Api.TimeZone/.docker/docker-compose.yml @@ -8,6 +8,8 @@ services: build: context: ../Api.TimeZone dockerfile: "Dockerfile.Local" + env_file: + - .env networks: - network diff --git a/Api.Versioning/.docker/docker-compose.dcproj b/Api.Versioning/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Api.Versioning/.docker/docker-compose.dcproj +++ b/Api.Versioning/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Api.Versioning/.docker/docker-compose.yml b/Api.Versioning/.docker/docker-compose.yml index c3a3fc80..97658f3c 100644 --- a/Api.Versioning/.docker/docker-compose.yml +++ b/Api.Versioning/.docker/docker-compose.yml @@ -8,6 +8,8 @@ services: build: context: ../Api.Versioning dockerfile: "Dockerfile.Local" + env_file: + - .env networks: - network diff --git a/Api.VirusScan/.docker/docker-compose.dcproj b/Api.VirusScan/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Api.VirusScan/.docker/docker-compose.dcproj +++ b/Api.VirusScan/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Api.VirusScan/.docker/docker-compose.yml b/Api.VirusScan/.docker/docker-compose.yml index 0d5b74ed..9830f14e 100644 --- a/Api.VirusScan/.docker/docker-compose.yml +++ b/Api.VirusScan/.docker/docker-compose.yml @@ -10,6 +10,8 @@ services: build: context: ../Api.VirusScan dockerfile: "Dockerfile.Local" + env_file: + - .env networks: - network diff --git a/Api._Blank/.docker/docker-compose.dcproj b/Api._Blank/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Api._Blank/.docker/docker-compose.dcproj +++ b/Api._Blank/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Api._Blank/.docker/docker-compose.yml b/Api._Blank/.docker/docker-compose.yml index a4aabf90..0f8ddbc1 100644 --- a/Api._Blank/.docker/docker-compose.yml +++ b/Api._Blank/.docker/docker-compose.yml @@ -8,6 +8,8 @@ services: build: context: ../Api.Blank dockerfile: "Dockerfile.Local" + env_file: + - .env networks: - network diff --git a/Console.CustomConfigSection/.docker/docker-compose.dcproj b/Console.CustomConfigSection/.docker/docker-compose.dcproj index acca666d..e940ca82 100644 --- a/Console.CustomConfigSection/.docker/docker-compose.dcproj +++ b/Console.CustomConfigSection/.docker/docker-compose.dcproj @@ -11,5 +11,6 @@ + \ No newline at end of file diff --git a/Console.CustomConfigSection/.docker/docker-compose.yml b/Console.CustomConfigSection/.docker/docker-compose.yml index f055dd68..51fd1f5c 100644 --- a/Console.CustomConfigSection/.docker/docker-compose.yml +++ b/Console.CustomConfigSection/.docker/docker-compose.yml @@ -5,6 +5,8 @@ services: build: context: ../Console.CustomConfigSection dockerfile: "Dockerfile.Local" + env_file: + - .env networks: - network diff --git a/Console.CustomService/.docker/docker-compose.dcproj b/Console.CustomService/.docker/docker-compose.dcproj index acca666d..e940ca82 100644 --- a/Console.CustomService/.docker/docker-compose.dcproj +++ b/Console.CustomService/.docker/docker-compose.dcproj @@ -11,5 +11,6 @@ + \ No newline at end of file diff --git a/Console.CustomService/.docker/docker-compose.yml b/Console.CustomService/.docker/docker-compose.yml index e05f251f..b2c5ae25 100644 --- a/Console.CustomService/.docker/docker-compose.yml +++ b/Console.CustomService/.docker/docker-compose.yml @@ -5,6 +5,8 @@ services: build: context: ../Console.CustomService dockerfile: "Dockerfile.Local" + env_file: + - .env networks: - network diff --git a/Console.Data.InMemory/.docker/docker-compose.dcproj b/Console.Data.InMemory/.docker/docker-compose.dcproj index acca666d..e940ca82 100644 --- a/Console.Data.InMemory/.docker/docker-compose.dcproj +++ b/Console.Data.InMemory/.docker/docker-compose.dcproj @@ -11,5 +11,6 @@ + \ No newline at end of file diff --git a/Console.Data.InMemory/.docker/docker-compose.yml b/Console.Data.InMemory/.docker/docker-compose.yml index e72d1145..367b2001 100644 --- a/Console.Data.InMemory/.docker/docker-compose.yml +++ b/Console.Data.InMemory/.docker/docker-compose.yml @@ -5,6 +5,8 @@ services: build: context: ../Console.Data.InMemory dockerfile: "Dockerfile.Local" + env_file: + - .env networks: - network diff --git a/Console.Data.MySql/.docker/docker-compose.dcproj b/Console.Data.MySql/.docker/docker-compose.dcproj index acca666d..e940ca82 100644 --- a/Console.Data.MySql/.docker/docker-compose.dcproj +++ b/Console.Data.MySql/.docker/docker-compose.dcproj @@ -11,5 +11,6 @@ + \ No newline at end of file diff --git a/Console.Data.MySql/.docker/docker-compose.yml b/Console.Data.MySql/.docker/docker-compose.yml index 3a849720..980da933 100644 --- a/Console.Data.MySql/.docker/docker-compose.yml +++ b/Console.Data.MySql/.docker/docker-compose.yml @@ -5,6 +5,8 @@ services: build: context: ../Console.Data.MySql dockerfile: "Dockerfile.Local" + env_file: + - .env depends_on: - database networks: diff --git a/Console.Data.PostgreSQL/.docker/docker-compose.dcproj b/Console.Data.PostgreSQL/.docker/docker-compose.dcproj index acca666d..e940ca82 100644 --- a/Console.Data.PostgreSQL/.docker/docker-compose.dcproj +++ b/Console.Data.PostgreSQL/.docker/docker-compose.dcproj @@ -11,5 +11,6 @@ + \ No newline at end of file diff --git a/Console.Data.PostgreSQL/.docker/docker-compose.yml b/Console.Data.PostgreSQL/.docker/docker-compose.yml index c3f9dac1..ee3bf825 100644 --- a/Console.Data.PostgreSQL/.docker/docker-compose.yml +++ b/Console.Data.PostgreSQL/.docker/docker-compose.yml @@ -5,6 +5,8 @@ services: build: context: ../Console.Data.PostgreSQL dockerfile: "Dockerfile.Local" + env_file: + - .env depends_on: - database networks: diff --git a/Console.Data.SqLite/.docker/docker-compose.dcproj b/Console.Data.SqLite/.docker/docker-compose.dcproj index acca666d..e940ca82 100644 --- a/Console.Data.SqLite/.docker/docker-compose.dcproj +++ b/Console.Data.SqLite/.docker/docker-compose.dcproj @@ -11,5 +11,6 @@ + \ No newline at end of file diff --git a/Console.Data.SqLite/.docker/docker-compose.yml b/Console.Data.SqLite/.docker/docker-compose.yml index 2d2a2508..f25d0e16 100644 --- a/Console.Data.SqLite/.docker/docker-compose.yml +++ b/Console.Data.SqLite/.docker/docker-compose.yml @@ -5,6 +5,8 @@ services: build: context: ../Console.Data.SqLite dockerfile: "Dockerfile.Local" + env_file: + - .env volumes: - ./bin/data:/mnt/data networks: diff --git a/Console.Data.SqlServer/.docker/docker-compose.dcproj b/Console.Data.SqlServer/.docker/docker-compose.dcproj index acca666d..e940ca82 100644 --- a/Console.Data.SqlServer/.docker/docker-compose.dcproj +++ b/Console.Data.SqlServer/.docker/docker-compose.dcproj @@ -11,5 +11,6 @@ + \ No newline at end of file diff --git a/Console.Data.SqlServer/.docker/docker-compose.yml b/Console.Data.SqlServer/.docker/docker-compose.yml index 423dc083..e15e3a26 100644 --- a/Console.Data.SqlServer/.docker/docker-compose.yml +++ b/Console.Data.SqlServer/.docker/docker-compose.yml @@ -5,6 +5,8 @@ services: build: context: ../Console.Data.SqlServer dockerfile: "Dockerfile.Local" + env_file: + - .env depends_on: - database networks: diff --git a/Console.Eventing.RabbitMq/.docker/docker-compose.dcproj b/Console.Eventing.RabbitMq/.docker/docker-compose.dcproj index acca666d..e940ca82 100644 --- a/Console.Eventing.RabbitMq/.docker/docker-compose.dcproj +++ b/Console.Eventing.RabbitMq/.docker/docker-compose.dcproj @@ -11,5 +11,6 @@ + \ No newline at end of file diff --git a/Console.Eventing.RabbitMq/.docker/docker-compose.yml b/Console.Eventing.RabbitMq/.docker/docker-compose.yml index 466eb2f3..272aaa45 100644 --- a/Console.Eventing.RabbitMq/.docker/docker-compose.yml +++ b/Console.Eventing.RabbitMq/.docker/docker-compose.yml @@ -5,6 +5,8 @@ services: build: context: ../Console.Eventing.RabbitMq dockerfile: "Dockerfile.Local" + env_file: + - .env depends_on: - eventing networks: diff --git a/Console.ExceptionHandling/.docker/docker-compose.dcproj b/Console.ExceptionHandling/.docker/docker-compose.dcproj index acca666d..e940ca82 100644 --- a/Console.ExceptionHandling/.docker/docker-compose.dcproj +++ b/Console.ExceptionHandling/.docker/docker-compose.dcproj @@ -11,5 +11,6 @@ + \ No newline at end of file diff --git a/Console.ExceptionHandling/.docker/docker-compose.yml b/Console.ExceptionHandling/.docker/docker-compose.yml index 25043431..ded7a5ff 100644 --- a/Console.ExceptionHandling/.docker/docker-compose.yml +++ b/Console.ExceptionHandling/.docker/docker-compose.yml @@ -5,6 +5,8 @@ services: build: context: ../Console.ExceptionHandling dockerfile: "Dockerfile.Local" + env_file: + - .env networks: - network diff --git a/Console.Localization/.docker/docker-compose.dcproj b/Console.Localization/.docker/docker-compose.dcproj index acca666d..e940ca82 100644 --- a/Console.Localization/.docker/docker-compose.dcproj +++ b/Console.Localization/.docker/docker-compose.dcproj @@ -11,5 +11,6 @@ + \ No newline at end of file diff --git a/Console.Localization/.docker/docker-compose.yml b/Console.Localization/.docker/docker-compose.yml index 26aa2436..de26fbda 100644 --- a/Console.Localization/.docker/docker-compose.yml +++ b/Console.Localization/.docker/docker-compose.yml @@ -5,6 +5,8 @@ services: build: context: ../Console.Localization dockerfile: "Dockerfile.Local" + env_file: + - .env networks: - network diff --git a/Console.Logging.Log4Net/.docker/docker-compose.dcproj b/Console.Logging.Log4Net/.docker/docker-compose.dcproj index acca666d..e940ca82 100644 --- a/Console.Logging.Log4Net/.docker/docker-compose.dcproj +++ b/Console.Logging.Log4Net/.docker/docker-compose.dcproj @@ -11,5 +11,6 @@ + \ No newline at end of file diff --git a/Console.Logging.Log4Net/.docker/docker-compose.yml b/Console.Logging.Log4Net/.docker/docker-compose.yml index 61a250c9..dd6c1a8e 100644 --- a/Console.Logging.Log4Net/.docker/docker-compose.yml +++ b/Console.Logging.Log4Net/.docker/docker-compose.yml @@ -5,6 +5,8 @@ services: build: context: ../Console.Logging.Log4Net dockerfile: "Dockerfile.Local" + env_file: + - .env networks: - network diff --git a/Console.Logging.Microsoft/.docker/docker-compose.dcproj b/Console.Logging.Microsoft/.docker/docker-compose.dcproj index acca666d..e940ca82 100644 --- a/Console.Logging.Microsoft/.docker/docker-compose.dcproj +++ b/Console.Logging.Microsoft/.docker/docker-compose.dcproj @@ -11,5 +11,6 @@ + \ No newline at end of file diff --git a/Console.Logging.Microsoft/.docker/docker-compose.yml b/Console.Logging.Microsoft/.docker/docker-compose.yml index 98d18205..4e91a588 100644 --- a/Console.Logging.Microsoft/.docker/docker-compose.yml +++ b/Console.Logging.Microsoft/.docker/docker-compose.yml @@ -5,6 +5,8 @@ services: build: context: ../Console.Logging.Microsoft dockerfile: "Dockerfile.Local" + env_file: + - .env networks: - network diff --git a/Console.Logging.NLog/.docker/docker-compose.dcproj b/Console.Logging.NLog/.docker/docker-compose.dcproj index acca666d..e940ca82 100644 --- a/Console.Logging.NLog/.docker/docker-compose.dcproj +++ b/Console.Logging.NLog/.docker/docker-compose.dcproj @@ -11,5 +11,6 @@ + \ No newline at end of file diff --git a/Console.Logging.NLog/.docker/docker-compose.yml b/Console.Logging.NLog/.docker/docker-compose.yml index aeb82911..91b0d875 100644 --- a/Console.Logging.NLog/.docker/docker-compose.yml +++ b/Console.Logging.NLog/.docker/docker-compose.yml @@ -5,6 +5,8 @@ services: build: context: ../Console.Logging.NLog dockerfile: "Dockerfile.Local" + env_file: + - .env networks: - network diff --git a/Console.Logging.Serilog/.docker/docker-compose.dcproj b/Console.Logging.Serilog/.docker/docker-compose.dcproj index acca666d..e940ca82 100644 --- a/Console.Logging.Serilog/.docker/docker-compose.dcproj +++ b/Console.Logging.Serilog/.docker/docker-compose.dcproj @@ -11,5 +11,6 @@ + \ No newline at end of file diff --git a/Console.Logging.Serilog/.docker/docker-compose.yml b/Console.Logging.Serilog/.docker/docker-compose.yml index b28a3fb6..7bc0d9aa 100644 --- a/Console.Logging.Serilog/.docker/docker-compose.yml +++ b/Console.Logging.Serilog/.docker/docker-compose.yml @@ -5,6 +5,8 @@ services: build: context: ../Console.Logging.Serilog dockerfile: "Dockerfile.Local" + env_file: + - .env networks: - network diff --git a/Console.StartupTasks/.docker/docker-compose.dcproj b/Console.StartupTasks/.docker/docker-compose.dcproj index acca666d..e940ca82 100644 --- a/Console.StartupTasks/.docker/docker-compose.dcproj +++ b/Console.StartupTasks/.docker/docker-compose.dcproj @@ -11,5 +11,6 @@ + \ No newline at end of file diff --git a/Console.StartupTasks/.docker/docker-compose.yml b/Console.StartupTasks/.docker/docker-compose.yml index b1212011..d721392c 100644 --- a/Console.StartupTasks/.docker/docker-compose.yml +++ b/Console.StartupTasks/.docker/docker-compose.yml @@ -5,6 +5,8 @@ services: build: context: ../Console.StartupTasks dockerfile: "Dockerfile.Local" + env_file: + - .env networks: - network diff --git a/Console.Storage.Azure/.docker/docker-compose.dcproj b/Console.Storage.Azure/.docker/docker-compose.dcproj index acca666d..e940ca82 100644 --- a/Console.Storage.Azure/.docker/docker-compose.dcproj +++ b/Console.Storage.Azure/.docker/docker-compose.dcproj @@ -11,5 +11,6 @@ + \ No newline at end of file diff --git a/Console.Storage.Azure/.docker/docker-compose.yml b/Console.Storage.Azure/.docker/docker-compose.yml index 0750c797..afc43570 100644 --- a/Console.Storage.Azure/.docker/docker-compose.yml +++ b/Console.Storage.Azure/.docker/docker-compose.yml @@ -5,6 +5,8 @@ services: build: context: ../Console.Storage.Azure dockerfile: "Dockerfile.Local" + env_file: + - .env networks: - network volumes: diff --git a/Console.Storage.Local/.docker/docker-compose.dcproj b/Console.Storage.Local/.docker/docker-compose.dcproj index acca666d..e940ca82 100644 --- a/Console.Storage.Local/.docker/docker-compose.dcproj +++ b/Console.Storage.Local/.docker/docker-compose.dcproj @@ -11,5 +11,6 @@ + \ No newline at end of file diff --git a/Console.Storage.Local/.docker/docker-compose.yml b/Console.Storage.Local/.docker/docker-compose.yml index f877ac93..4237dafe 100644 --- a/Console.Storage.Local/.docker/docker-compose.yml +++ b/Console.Storage.Local/.docker/docker-compose.yml @@ -5,6 +5,8 @@ services: build: context: ../Console.Storage.Local dockerfile: "Dockerfile.Local" + env_file: + - .env networks: - network volumes: diff --git a/Console.Workers/.docker/docker-compose.dcproj b/Console.Workers/.docker/docker-compose.dcproj index acca666d..e940ca82 100644 --- a/Console.Workers/.docker/docker-compose.dcproj +++ b/Console.Workers/.docker/docker-compose.dcproj @@ -11,5 +11,6 @@ + \ No newline at end of file diff --git a/Console.Workers/.docker/docker-compose.yml b/Console.Workers/.docker/docker-compose.yml index 4c2e3672..040ad9e4 100644 --- a/Console.Workers/.docker/docker-compose.yml +++ b/Console.Workers/.docker/docker-compose.yml @@ -5,6 +5,8 @@ services: build: context: ../Console.Workers dockerfile: "Dockerfile.Local" + env_file: + - .env networks: - network diff --git a/Console._Blank/.docker/docker-compose.dcproj b/Console._Blank/.docker/docker-compose.dcproj index acca666d..e940ca82 100644 --- a/Console._Blank/.docker/docker-compose.dcproj +++ b/Console._Blank/.docker/docker-compose.dcproj @@ -11,5 +11,6 @@ + \ No newline at end of file diff --git a/Console._Blank/.docker/docker-compose.yml b/Console._Blank/.docker/docker-compose.yml index de34d983..8080c540 100644 --- a/Console._Blank/.docker/docker-compose.yml +++ b/Console._Blank/.docker/docker-compose.yml @@ -5,6 +5,8 @@ services: build: context: ../Console.Blank dockerfile: "Dockerfile.Local" + env_file: + - .env networks: - network diff --git a/Web._Blank/.docker/docker-compose.dcproj b/Web._Blank/.docker/docker-compose.dcproj index d9e8e500..0788f536 100644 --- a/Web._Blank/.docker/docker-compose.dcproj +++ b/Web._Blank/.docker/docker-compose.dcproj @@ -9,5 +9,6 @@ + \ No newline at end of file diff --git a/Web._Blank/.docker/docker-compose.yml b/Web._Blank/.docker/docker-compose.yml index 54765d3c..8cd53016 100644 --- a/Web._Blank/.docker/docker-compose.yml +++ b/Web._Blank/.docker/docker-compose.yml @@ -8,6 +8,8 @@ services: build: context: ../Web.Blank dockerfile: "Dockerfile.Local" + env_file: + - .env networks: - network From 7bbd1913e05faefd84efb90d5dd9d2b1a4737bc5 Mon Sep 17 00:00:00 2001 From: vivet Date: Tue, 22 Sep 2026 09:29:51 +0200 Subject: [PATCH 04/13] Updated --- .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Api.ApiClients.Audit/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Api.ApiClients.Entity/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Api.ApiClients.RootLogIn/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Api.ApiClients/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Api.Auth.External.Custom/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Api.Auth.External.Microsoft/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Api.Auth.RootLogin/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Api.Authorization/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Api.ContentNegotiation/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Api.Cookies/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Api.CustomConfigSection/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Api.CustomMiddleware/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Api.CustomService/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Api.Data.Audit/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Api.Data.EntityEvents/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Api.Data.Identity.Auth.ApiKey/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- .../AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Api.Data.Identity.Auth.Jwt/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Api.Data.Identity/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Api.Data.InMemory/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Api.Data.LazyLoading/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Api.Data.MySql.Collation/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Api.Data.MySql.Mappings/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Api.Data.MySql.Spatial/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Api.Data.MySql.StoredProcedures/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Api.Data.MySql.Views/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Api.Data.MySql/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Api.Data.PostgreSQL.Spatial/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Api.Data.PostgreSQL/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Api.Data.Repository.AutoSave/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Api.Data.Repository.Includes/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Api.Data.SoftDelete/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Api.Data.SqLite/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Api.Data.SqlServer.Spatial/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Api.Data.SqlServer/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Api.Data.Triggers/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Api.Documentation.Csp/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Api.Documentation/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Api.ErrorHandling/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Api.Eventing.RabbitMq/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Api.HealthChecks/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Api.Hosting.Http/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Api.Hosting.Https/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Api.Hosting.MultipartLimits/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Api.Localization/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Api.Logging.Log4Net/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Api.Logging.Microsoft/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Api.Logging.NLog/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Api.Logging.Serilog/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Api.Metrics/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Api.MultipartJson/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- .../AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- .../AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Api.PolicyHeaders.Cors/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Api.PolicyHeaders.ForwardedHeaders/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Api.PolicyHeaders.FrameOptions/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Api.PolicyHeaders.Hsts/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Api.PolicyHeaders.ReferrerPolicy/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Api.PolicyHeaders.Robots/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Api.PolicyHeaders.XssProtection/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Api.RequestTracing/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Api.ResponseCache/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Api.ResponseCompression/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Api.Session/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Api.StartupTasks/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Api.StaticFiles/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Api.Storage.Azure/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Api.Storage.Local/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Api.TimeZone/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Api.Versioning/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Api.VirusScan/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Api._Blank/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Console.CustomConfigSection/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Console.CustomService/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Console.Data.InMemory/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Console.Data.MySql/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Console.Data.PostgreSQL/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Console.Data.SqLite/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Console.Data.SqlServer/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Console.Eventing.RabbitMq/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Console.ExceptionHandling/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Console.Localization/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Console.Logging.Log4Net/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Console.Logging.Microsoft/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Console.Logging.NLog/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Console.Logging.Serilog/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Console.StartupTasks/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Console.Storage.Azure/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Console.Storage.Local/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Console.Workers/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Console._Blank/AGENTS.md | 27 ++++++ .../SKILL.md | 8 +- ...ano-add-api-client-configuration.prompt.md | 90 ++++++++++--------- Web._Blank/AGENTS.md | 27 ++++++ 276 files changed, 7544 insertions(+), 3956 deletions(-) diff --git a/Api.ApiClients.Audit/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.ApiClients.Audit/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Api.ApiClients.Audit/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.ApiClients.Audit/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Api.ApiClients.Audit/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.ApiClients.Audit/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Api.ApiClients.Audit/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.ApiClients.Audit/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.ApiClients.Audit/AGENTS.md b/Api.ApiClients.Audit/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Api.ApiClients.Audit/AGENTS.md +++ b/Api.ApiClients.Audit/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Api.ApiClients.Entity/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.ApiClients.Entity/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Api.ApiClients.Entity/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.ApiClients.Entity/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Api.ApiClients.Entity/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.ApiClients.Entity/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Api.ApiClients.Entity/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.ApiClients.Entity/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.ApiClients.Entity/AGENTS.md b/Api.ApiClients.Entity/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Api.ApiClients.Entity/AGENTS.md +++ b/Api.ApiClients.Entity/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Api.ApiClients.RootLogIn/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.ApiClients.RootLogIn/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Api.ApiClients.RootLogIn/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.ApiClients.RootLogIn/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Api.ApiClients.RootLogIn/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Api.ApiClients.RootLogIn/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.ApiClients.RootLogIn/AGENTS.md b/Api.ApiClients.RootLogIn/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Api.ApiClients.RootLogIn/AGENTS.md +++ b/Api.ApiClients.RootLogIn/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Api.ApiClients/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.ApiClients/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Api.ApiClients/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.ApiClients/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Api.ApiClients/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.ApiClients/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Api.ApiClients/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.ApiClients/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.ApiClients/AGENTS.md b/Api.ApiClients/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Api.ApiClients/AGENTS.md +++ b/Api.ApiClients/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Api.Auth.External.Custom/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Auth.External.Custom/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Api.Auth.External.Custom/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Auth.External.Custom/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Api.Auth.External.Custom/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Auth.External.Custom/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Api.Auth.External.Custom/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Auth.External.Custom/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.Auth.External.Custom/AGENTS.md b/Api.Auth.External.Custom/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Api.Auth.External.Custom/AGENTS.md +++ b/Api.Auth.External.Custom/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Api.Auth.External.Microsoft/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Auth.External.Microsoft/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Api.Auth.External.Microsoft/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Auth.External.Microsoft/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Api.Auth.External.Microsoft/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Auth.External.Microsoft/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Api.Auth.External.Microsoft/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Auth.External.Microsoft/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.Auth.External.Microsoft/AGENTS.md b/Api.Auth.External.Microsoft/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Api.Auth.External.Microsoft/AGENTS.md +++ b/Api.Auth.External.Microsoft/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Api.Auth.RootLogin/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Auth.RootLogin/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Api.Auth.RootLogin/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Auth.RootLogin/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Api.Auth.RootLogin/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Auth.RootLogin/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Api.Auth.RootLogin/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Auth.RootLogin/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.Auth.RootLogin/AGENTS.md b/Api.Auth.RootLogin/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Api.Auth.RootLogin/AGENTS.md +++ b/Api.Auth.RootLogin/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Api.Authorization/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Authorization/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Api.Authorization/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Authorization/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Api.Authorization/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Authorization/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Api.Authorization/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Authorization/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.Authorization/AGENTS.md b/Api.Authorization/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Api.Authorization/AGENTS.md +++ b/Api.Authorization/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Api.ContentNegotiation/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.ContentNegotiation/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Api.ContentNegotiation/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.ContentNegotiation/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Api.ContentNegotiation/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.ContentNegotiation/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Api.ContentNegotiation/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.ContentNegotiation/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.ContentNegotiation/AGENTS.md b/Api.ContentNegotiation/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Api.ContentNegotiation/AGENTS.md +++ b/Api.ContentNegotiation/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Api.Cookies/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Cookies/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Api.Cookies/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Cookies/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Api.Cookies/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Cookies/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Api.Cookies/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Cookies/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.Cookies/AGENTS.md b/Api.Cookies/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Api.Cookies/AGENTS.md +++ b/Api.Cookies/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Api.CustomConfigSection/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.CustomConfigSection/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Api.CustomConfigSection/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.CustomConfigSection/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Api.CustomConfigSection/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.CustomConfigSection/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Api.CustomConfigSection/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.CustomConfigSection/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.CustomConfigSection/AGENTS.md b/Api.CustomConfigSection/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Api.CustomConfigSection/AGENTS.md +++ b/Api.CustomConfigSection/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Api.CustomMiddleware/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.CustomMiddleware/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Api.CustomMiddleware/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.CustomMiddleware/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Api.CustomMiddleware/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.CustomMiddleware/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Api.CustomMiddleware/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.CustomMiddleware/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.CustomMiddleware/AGENTS.md b/Api.CustomMiddleware/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Api.CustomMiddleware/AGENTS.md +++ b/Api.CustomMiddleware/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Api.CustomService/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.CustomService/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Api.CustomService/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.CustomService/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Api.CustomService/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.CustomService/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Api.CustomService/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.CustomService/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.CustomService/AGENTS.md b/Api.CustomService/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Api.CustomService/AGENTS.md +++ b/Api.CustomService/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Api.Data.Audit/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Data.Audit/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Api.Data.Audit/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Data.Audit/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Api.Data.Audit/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Data.Audit/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Api.Data.Audit/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Data.Audit/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.Data.Audit/AGENTS.md b/Api.Data.Audit/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Api.Data.Audit/AGENTS.md +++ b/Api.Data.Audit/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Api.Data.EntityEvents/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Data.EntityEvents/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Api.Data.EntityEvents/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Data.EntityEvents/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Api.Data.EntityEvents/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Data.EntityEvents/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Api.Data.EntityEvents/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Data.EntityEvents/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.Data.EntityEvents/AGENTS.md b/Api.Data.EntityEvents/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Api.Data.EntityEvents/AGENTS.md +++ b/Api.Data.EntityEvents/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.Data.Identity.Auth.ApiKey/AGENTS.md b/Api.Data.Identity.Auth.ApiKey/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Api.Data.Identity.Auth.ApiKey/AGENTS.md +++ b/Api.Data.Identity.Auth.ApiKey/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.Data.Identity.Auth.External.Custom/AGENTS.md b/Api.Data.Identity.Auth.External.Custom/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Api.Data.Identity.Auth.External.Custom/AGENTS.md +++ b/Api.Data.Identity.Auth.External.Custom/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.Data.Identity.Auth.Jwt/AGENTS.md b/Api.Data.Identity.Auth.Jwt/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Api.Data.Identity.Auth.Jwt/AGENTS.md +++ b/Api.Data.Identity.Auth.Jwt/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Api.Data.Identity/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Data.Identity/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Api.Data.Identity/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Data.Identity/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Api.Data.Identity/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Data.Identity/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Api.Data.Identity/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Data.Identity/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.Data.Identity/AGENTS.md b/Api.Data.Identity/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Api.Data.Identity/AGENTS.md +++ b/Api.Data.Identity/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Api.Data.InMemory/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Data.InMemory/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Api.Data.InMemory/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Data.InMemory/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Api.Data.InMemory/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Data.InMemory/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Api.Data.InMemory/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Data.InMemory/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.Data.InMemory/AGENTS.md b/Api.Data.InMemory/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Api.Data.InMemory/AGENTS.md +++ b/Api.Data.InMemory/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Api.Data.LazyLoading/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Data.LazyLoading/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Api.Data.LazyLoading/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Data.LazyLoading/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Api.Data.LazyLoading/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Data.LazyLoading/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Api.Data.LazyLoading/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Data.LazyLoading/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.Data.LazyLoading/AGENTS.md b/Api.Data.LazyLoading/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Api.Data.LazyLoading/AGENTS.md +++ b/Api.Data.LazyLoading/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Api.Data.MySql.Collation/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Data.MySql.Collation/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Api.Data.MySql.Collation/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Data.MySql.Collation/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Api.Data.MySql.Collation/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Data.MySql.Collation/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Api.Data.MySql.Collation/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Data.MySql.Collation/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.Data.MySql.Collation/AGENTS.md b/Api.Data.MySql.Collation/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Api.Data.MySql.Collation/AGENTS.md +++ b/Api.Data.MySql.Collation/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Api.Data.MySql.Mappings/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Data.MySql.Mappings/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Api.Data.MySql.Mappings/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Data.MySql.Mappings/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Api.Data.MySql.Mappings/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Data.MySql.Mappings/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Api.Data.MySql.Mappings/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Data.MySql.Mappings/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.Data.MySql.Mappings/AGENTS.md b/Api.Data.MySql.Mappings/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Api.Data.MySql.Mappings/AGENTS.md +++ b/Api.Data.MySql.Mappings/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Api.Data.MySql.Spatial/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Data.MySql.Spatial/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Api.Data.MySql.Spatial/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Data.MySql.Spatial/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Api.Data.MySql.Spatial/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Data.MySql.Spatial/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Api.Data.MySql.Spatial/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Data.MySql.Spatial/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.Data.MySql.Spatial/AGENTS.md b/Api.Data.MySql.Spatial/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Api.Data.MySql.Spatial/AGENTS.md +++ b/Api.Data.MySql.Spatial/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.Data.MySql.StoredProcedures/AGENTS.md b/Api.Data.MySql.StoredProcedures/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Api.Data.MySql.StoredProcedures/AGENTS.md +++ b/Api.Data.MySql.StoredProcedures/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Api.Data.MySql.Views/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Data.MySql.Views/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Api.Data.MySql.Views/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Data.MySql.Views/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Api.Data.MySql.Views/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Data.MySql.Views/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Api.Data.MySql.Views/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Data.MySql.Views/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.Data.MySql.Views/AGENTS.md b/Api.Data.MySql.Views/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Api.Data.MySql.Views/AGENTS.md +++ b/Api.Data.MySql.Views/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Api.Data.MySql/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Data.MySql/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Api.Data.MySql/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Data.MySql/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Api.Data.MySql/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Data.MySql/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Api.Data.MySql/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Data.MySql/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.Data.MySql/AGENTS.md b/Api.Data.MySql/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Api.Data.MySql/AGENTS.md +++ b/Api.Data.MySql/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.Data.PostgreSQL.Spatial/AGENTS.md b/Api.Data.PostgreSQL.Spatial/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Api.Data.PostgreSQL.Spatial/AGENTS.md +++ b/Api.Data.PostgreSQL.Spatial/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Api.Data.PostgreSQL/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Data.PostgreSQL/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Api.Data.PostgreSQL/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Data.PostgreSQL/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Api.Data.PostgreSQL/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Data.PostgreSQL/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Api.Data.PostgreSQL/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Data.PostgreSQL/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.Data.PostgreSQL/AGENTS.md b/Api.Data.PostgreSQL/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Api.Data.PostgreSQL/AGENTS.md +++ b/Api.Data.PostgreSQL/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Api.Data.Repository.AutoSave/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Data.Repository.AutoSave/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Api.Data.Repository.AutoSave/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Data.Repository.AutoSave/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Api.Data.Repository.AutoSave/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Data.Repository.AutoSave/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Api.Data.Repository.AutoSave/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Data.Repository.AutoSave/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.Data.Repository.AutoSave/AGENTS.md b/Api.Data.Repository.AutoSave/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Api.Data.Repository.AutoSave/AGENTS.md +++ b/Api.Data.Repository.AutoSave/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Api.Data.Repository.Includes/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Data.Repository.Includes/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Api.Data.Repository.Includes/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Data.Repository.Includes/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Api.Data.Repository.Includes/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Data.Repository.Includes/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Api.Data.Repository.Includes/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Data.Repository.Includes/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.Data.Repository.Includes/AGENTS.md b/Api.Data.Repository.Includes/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Api.Data.Repository.Includes/AGENTS.md +++ b/Api.Data.Repository.Includes/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Api.Data.SoftDelete/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Data.SoftDelete/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Api.Data.SoftDelete/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Data.SoftDelete/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Api.Data.SoftDelete/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Data.SoftDelete/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Api.Data.SoftDelete/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Data.SoftDelete/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.Data.SoftDelete/AGENTS.md b/Api.Data.SoftDelete/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Api.Data.SoftDelete/AGENTS.md +++ b/Api.Data.SoftDelete/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Api.Data.SqLite/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Data.SqLite/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Api.Data.SqLite/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Data.SqLite/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Api.Data.SqLite/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Data.SqLite/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Api.Data.SqLite/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Data.SqLite/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.Data.SqLite/AGENTS.md b/Api.Data.SqLite/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Api.Data.SqLite/AGENTS.md +++ b/Api.Data.SqLite/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.Data.SqlServer.Spatial/AGENTS.md b/Api.Data.SqlServer.Spatial/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Api.Data.SqlServer.Spatial/AGENTS.md +++ b/Api.Data.SqlServer.Spatial/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Api.Data.SqlServer/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Data.SqlServer/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Api.Data.SqlServer/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Data.SqlServer/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Api.Data.SqlServer/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Data.SqlServer/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Api.Data.SqlServer/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Data.SqlServer/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.Data.SqlServer/AGENTS.md b/Api.Data.SqlServer/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Api.Data.SqlServer/AGENTS.md +++ b/Api.Data.SqlServer/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Api.Data.Triggers/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Data.Triggers/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Api.Data.Triggers/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Data.Triggers/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Api.Data.Triggers/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Data.Triggers/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Api.Data.Triggers/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Data.Triggers/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.Data.Triggers/AGENTS.md b/Api.Data.Triggers/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Api.Data.Triggers/AGENTS.md +++ b/Api.Data.Triggers/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Api.Documentation.Csp/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Documentation.Csp/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Api.Documentation.Csp/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Documentation.Csp/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Api.Documentation.Csp/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Documentation.Csp/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Api.Documentation.Csp/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Documentation.Csp/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.Documentation.Csp/AGENTS.md b/Api.Documentation.Csp/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Api.Documentation.Csp/AGENTS.md +++ b/Api.Documentation.Csp/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Api.Documentation/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Documentation/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Api.Documentation/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Documentation/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Api.Documentation/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Documentation/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Api.Documentation/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Documentation/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.Documentation/AGENTS.md b/Api.Documentation/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Api.Documentation/AGENTS.md +++ b/Api.Documentation/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Api.ErrorHandling/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.ErrorHandling/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Api.ErrorHandling/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.ErrorHandling/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Api.ErrorHandling/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.ErrorHandling/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Api.ErrorHandling/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.ErrorHandling/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.ErrorHandling/AGENTS.md b/Api.ErrorHandling/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Api.ErrorHandling/AGENTS.md +++ b/Api.ErrorHandling/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Api.Eventing.RabbitMq/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Eventing.RabbitMq/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Api.Eventing.RabbitMq/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Eventing.RabbitMq/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Api.Eventing.RabbitMq/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Eventing.RabbitMq/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Api.Eventing.RabbitMq/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Eventing.RabbitMq/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.Eventing.RabbitMq/AGENTS.md b/Api.Eventing.RabbitMq/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Api.Eventing.RabbitMq/AGENTS.md +++ b/Api.Eventing.RabbitMq/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Api.HealthChecks/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.HealthChecks/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Api.HealthChecks/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.HealthChecks/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Api.HealthChecks/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.HealthChecks/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Api.HealthChecks/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.HealthChecks/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.HealthChecks/AGENTS.md b/Api.HealthChecks/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Api.HealthChecks/AGENTS.md +++ b/Api.HealthChecks/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Api.Hosting.Http/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Hosting.Http/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Api.Hosting.Http/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Hosting.Http/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Api.Hosting.Http/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Hosting.Http/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Api.Hosting.Http/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Hosting.Http/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.Hosting.Http/AGENTS.md b/Api.Hosting.Http/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Api.Hosting.Http/AGENTS.md +++ b/Api.Hosting.Http/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Api.Hosting.Https/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Hosting.Https/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Api.Hosting.Https/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Hosting.Https/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Api.Hosting.Https/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Hosting.Https/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Api.Hosting.Https/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Hosting.Https/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.Hosting.Https/AGENTS.md b/Api.Hosting.Https/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Api.Hosting.Https/AGENTS.md +++ b/Api.Hosting.Https/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Api.Hosting.MultipartLimits/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Hosting.MultipartLimits/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Api.Hosting.MultipartLimits/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Hosting.MultipartLimits/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Api.Hosting.MultipartLimits/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Hosting.MultipartLimits/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Api.Hosting.MultipartLimits/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Hosting.MultipartLimits/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.Hosting.MultipartLimits/AGENTS.md b/Api.Hosting.MultipartLimits/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Api.Hosting.MultipartLimits/AGENTS.md +++ b/Api.Hosting.MultipartLimits/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Api.Localization/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Localization/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Api.Localization/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Localization/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Api.Localization/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Localization/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Api.Localization/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Localization/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.Localization/AGENTS.md b/Api.Localization/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Api.Localization/AGENTS.md +++ b/Api.Localization/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Api.Logging.Log4Net/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Logging.Log4Net/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Api.Logging.Log4Net/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Logging.Log4Net/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Api.Logging.Log4Net/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Logging.Log4Net/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Api.Logging.Log4Net/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Logging.Log4Net/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.Logging.Log4Net/AGENTS.md b/Api.Logging.Log4Net/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Api.Logging.Log4Net/AGENTS.md +++ b/Api.Logging.Log4Net/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Api.Logging.Microsoft/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Logging.Microsoft/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Api.Logging.Microsoft/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Logging.Microsoft/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Api.Logging.Microsoft/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Logging.Microsoft/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Api.Logging.Microsoft/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Logging.Microsoft/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.Logging.Microsoft/AGENTS.md b/Api.Logging.Microsoft/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Api.Logging.Microsoft/AGENTS.md +++ b/Api.Logging.Microsoft/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Api.Logging.NLog/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Logging.NLog/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Api.Logging.NLog/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Logging.NLog/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Api.Logging.NLog/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Logging.NLog/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Api.Logging.NLog/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Logging.NLog/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.Logging.NLog/AGENTS.md b/Api.Logging.NLog/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Api.Logging.NLog/AGENTS.md +++ b/Api.Logging.NLog/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Api.Logging.Serilog/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Logging.Serilog/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Api.Logging.Serilog/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Logging.Serilog/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Api.Logging.Serilog/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Logging.Serilog/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Api.Logging.Serilog/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Logging.Serilog/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.Logging.Serilog/AGENTS.md b/Api.Logging.Serilog/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Api.Logging.Serilog/AGENTS.md +++ b/Api.Logging.Serilog/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Api.Metrics/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Metrics/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Api.Metrics/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Metrics/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Api.Metrics/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Metrics/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Api.Metrics/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Metrics/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.Metrics/AGENTS.md b/Api.Metrics/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Api.Metrics/AGENTS.md +++ b/Api.Metrics/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Api.MultipartJson/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.MultipartJson/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Api.MultipartJson/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.MultipartJson/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Api.MultipartJson/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.MultipartJson/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Api.MultipartJson/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.MultipartJson/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.MultipartJson/AGENTS.md b/Api.MultipartJson/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Api.MultipartJson/AGENTS.md +++ b/Api.MultipartJson/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.PolicyHeaders.ContentSecurityPolicy/AGENTS.md b/Api.PolicyHeaders.ContentSecurityPolicy/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Api.PolicyHeaders.ContentSecurityPolicy/AGENTS.md +++ b/Api.PolicyHeaders.ContentSecurityPolicy/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.PolicyHeaders.ContentTypeOptions/AGENTS.md b/Api.PolicyHeaders.ContentTypeOptions/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Api.PolicyHeaders.ContentTypeOptions/AGENTS.md +++ b/Api.PolicyHeaders.ContentTypeOptions/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Api.PolicyHeaders.Cors/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.PolicyHeaders.Cors/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Api.PolicyHeaders.Cors/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.PolicyHeaders.Cors/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Api.PolicyHeaders.Cors/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.PolicyHeaders.Cors/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Api.PolicyHeaders.Cors/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.PolicyHeaders.Cors/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.PolicyHeaders.Cors/AGENTS.md b/Api.PolicyHeaders.Cors/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Api.PolicyHeaders.Cors/AGENTS.md +++ b/Api.PolicyHeaders.Cors/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.PolicyHeaders.ForwardedHeaders/AGENTS.md b/Api.PolicyHeaders.ForwardedHeaders/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Api.PolicyHeaders.ForwardedHeaders/AGENTS.md +++ b/Api.PolicyHeaders.ForwardedHeaders/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.PolicyHeaders.FrameOptions/AGENTS.md b/Api.PolicyHeaders.FrameOptions/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Api.PolicyHeaders.FrameOptions/AGENTS.md +++ b/Api.PolicyHeaders.FrameOptions/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.PolicyHeaders.Hsts/AGENTS.md b/Api.PolicyHeaders.Hsts/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Api.PolicyHeaders.Hsts/AGENTS.md +++ b/Api.PolicyHeaders.Hsts/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.PolicyHeaders.ReferrerPolicy/AGENTS.md b/Api.PolicyHeaders.ReferrerPolicy/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Api.PolicyHeaders.ReferrerPolicy/AGENTS.md +++ b/Api.PolicyHeaders.ReferrerPolicy/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Api.PolicyHeaders.Robots/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.PolicyHeaders.Robots/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Api.PolicyHeaders.Robots/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.PolicyHeaders.Robots/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Api.PolicyHeaders.Robots/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.PolicyHeaders.Robots/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Api.PolicyHeaders.Robots/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.PolicyHeaders.Robots/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.PolicyHeaders.Robots/AGENTS.md b/Api.PolicyHeaders.Robots/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Api.PolicyHeaders.Robots/AGENTS.md +++ b/Api.PolicyHeaders.Robots/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.PolicyHeaders.XssProtection/AGENTS.md b/Api.PolicyHeaders.XssProtection/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Api.PolicyHeaders.XssProtection/AGENTS.md +++ b/Api.PolicyHeaders.XssProtection/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Api.RequestTracing/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.RequestTracing/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Api.RequestTracing/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.RequestTracing/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Api.RequestTracing/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.RequestTracing/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Api.RequestTracing/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.RequestTracing/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.RequestTracing/AGENTS.md b/Api.RequestTracing/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Api.RequestTracing/AGENTS.md +++ b/Api.RequestTracing/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Api.ResponseCache/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.ResponseCache/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Api.ResponseCache/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.ResponseCache/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Api.ResponseCache/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.ResponseCache/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Api.ResponseCache/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.ResponseCache/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.ResponseCache/AGENTS.md b/Api.ResponseCache/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Api.ResponseCache/AGENTS.md +++ b/Api.ResponseCache/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Api.ResponseCompression/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.ResponseCompression/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Api.ResponseCompression/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.ResponseCompression/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Api.ResponseCompression/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.ResponseCompression/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Api.ResponseCompression/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.ResponseCompression/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.ResponseCompression/AGENTS.md b/Api.ResponseCompression/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Api.ResponseCompression/AGENTS.md +++ b/Api.ResponseCompression/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Api.Session/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Session/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Api.Session/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Session/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Api.Session/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Session/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Api.Session/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Session/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.Session/AGENTS.md b/Api.Session/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Api.Session/AGENTS.md +++ b/Api.Session/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Api.StartupTasks/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.StartupTasks/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Api.StartupTasks/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.StartupTasks/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Api.StartupTasks/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.StartupTasks/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Api.StartupTasks/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.StartupTasks/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.StartupTasks/AGENTS.md b/Api.StartupTasks/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Api.StartupTasks/AGENTS.md +++ b/Api.StartupTasks/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Api.StaticFiles/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.StaticFiles/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Api.StaticFiles/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.StaticFiles/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Api.StaticFiles/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.StaticFiles/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Api.StaticFiles/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.StaticFiles/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.StaticFiles/AGENTS.md b/Api.StaticFiles/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Api.StaticFiles/AGENTS.md +++ b/Api.StaticFiles/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Api.Storage.Azure/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Storage.Azure/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Api.Storage.Azure/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Storage.Azure/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Api.Storage.Azure/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Storage.Azure/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Api.Storage.Azure/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Storage.Azure/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.Storage.Azure/AGENTS.md b/Api.Storage.Azure/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Api.Storage.Azure/AGENTS.md +++ b/Api.Storage.Azure/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Api.Storage.Local/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Storage.Local/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Api.Storage.Local/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Storage.Local/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Api.Storage.Local/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Storage.Local/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Api.Storage.Local/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Storage.Local/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.Storage.Local/AGENTS.md b/Api.Storage.Local/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Api.Storage.Local/AGENTS.md +++ b/Api.Storage.Local/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Api.TimeZone/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.TimeZone/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Api.TimeZone/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.TimeZone/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Api.TimeZone/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.TimeZone/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Api.TimeZone/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.TimeZone/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.TimeZone/AGENTS.md b/Api.TimeZone/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Api.TimeZone/AGENTS.md +++ b/Api.TimeZone/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Api.Versioning/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Versioning/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Api.Versioning/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Versioning/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Api.Versioning/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Versioning/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Api.Versioning/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Versioning/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.Versioning/AGENTS.md b/Api.Versioning/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Api.Versioning/AGENTS.md +++ b/Api.Versioning/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Api.VirusScan/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.VirusScan/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Api.VirusScan/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.VirusScan/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Api.VirusScan/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.VirusScan/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Api.VirusScan/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.VirusScan/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.VirusScan/AGENTS.md b/Api.VirusScan/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Api.VirusScan/AGENTS.md +++ b/Api.VirusScan/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Api._Blank/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api._Blank/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Api._Blank/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api._Blank/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Api._Blank/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api._Blank/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Api._Blank/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api._Blank/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api._Blank/AGENTS.md b/Api._Blank/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Api._Blank/AGENTS.md +++ b/Api._Blank/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Console.CustomConfigSection/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Console.CustomConfigSection/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Console.CustomConfigSection/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Console.CustomConfigSection/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Console.CustomConfigSection/.github/prompts/nano-add-api-client-configuration.prompt.md b/Console.CustomConfigSection/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Console.CustomConfigSection/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Console.CustomConfigSection/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Console.CustomConfigSection/AGENTS.md b/Console.CustomConfigSection/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Console.CustomConfigSection/AGENTS.md +++ b/Console.CustomConfigSection/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Console.CustomService/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Console.CustomService/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Console.CustomService/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Console.CustomService/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Console.CustomService/.github/prompts/nano-add-api-client-configuration.prompt.md b/Console.CustomService/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Console.CustomService/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Console.CustomService/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Console.CustomService/AGENTS.md b/Console.CustomService/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Console.CustomService/AGENTS.md +++ b/Console.CustomService/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Console.Data.InMemory/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Console.Data.InMemory/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Console.Data.InMemory/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Console.Data.InMemory/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Console.Data.InMemory/.github/prompts/nano-add-api-client-configuration.prompt.md b/Console.Data.InMemory/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Console.Data.InMemory/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Console.Data.InMemory/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Console.Data.InMemory/AGENTS.md b/Console.Data.InMemory/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Console.Data.InMemory/AGENTS.md +++ b/Console.Data.InMemory/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Console.Data.MySql/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Console.Data.MySql/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Console.Data.MySql/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Console.Data.MySql/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Console.Data.MySql/.github/prompts/nano-add-api-client-configuration.prompt.md b/Console.Data.MySql/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Console.Data.MySql/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Console.Data.MySql/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Console.Data.MySql/AGENTS.md b/Console.Data.MySql/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Console.Data.MySql/AGENTS.md +++ b/Console.Data.MySql/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Console.Data.PostgreSQL/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Console.Data.PostgreSQL/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Console.Data.PostgreSQL/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Console.Data.PostgreSQL/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Console.Data.PostgreSQL/.github/prompts/nano-add-api-client-configuration.prompt.md b/Console.Data.PostgreSQL/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Console.Data.PostgreSQL/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Console.Data.PostgreSQL/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Console.Data.PostgreSQL/AGENTS.md b/Console.Data.PostgreSQL/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Console.Data.PostgreSQL/AGENTS.md +++ b/Console.Data.PostgreSQL/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Console.Data.SqLite/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Console.Data.SqLite/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Console.Data.SqLite/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Console.Data.SqLite/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Console.Data.SqLite/.github/prompts/nano-add-api-client-configuration.prompt.md b/Console.Data.SqLite/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Console.Data.SqLite/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Console.Data.SqLite/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Console.Data.SqLite/AGENTS.md b/Console.Data.SqLite/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Console.Data.SqLite/AGENTS.md +++ b/Console.Data.SqLite/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Console.Data.SqlServer/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Console.Data.SqlServer/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Console.Data.SqlServer/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Console.Data.SqlServer/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Console.Data.SqlServer/.github/prompts/nano-add-api-client-configuration.prompt.md b/Console.Data.SqlServer/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Console.Data.SqlServer/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Console.Data.SqlServer/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Console.Data.SqlServer/AGENTS.md b/Console.Data.SqlServer/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Console.Data.SqlServer/AGENTS.md +++ b/Console.Data.SqlServer/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Console.Eventing.RabbitMq/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Console.Eventing.RabbitMq/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Console.Eventing.RabbitMq/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Console.Eventing.RabbitMq/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Console.Eventing.RabbitMq/.github/prompts/nano-add-api-client-configuration.prompt.md b/Console.Eventing.RabbitMq/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Console.Eventing.RabbitMq/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Console.Eventing.RabbitMq/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Console.Eventing.RabbitMq/AGENTS.md b/Console.Eventing.RabbitMq/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Console.Eventing.RabbitMq/AGENTS.md +++ b/Console.Eventing.RabbitMq/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Console.ExceptionHandling/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Console.ExceptionHandling/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Console.ExceptionHandling/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Console.ExceptionHandling/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Console.ExceptionHandling/.github/prompts/nano-add-api-client-configuration.prompt.md b/Console.ExceptionHandling/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Console.ExceptionHandling/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Console.ExceptionHandling/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Console.ExceptionHandling/AGENTS.md b/Console.ExceptionHandling/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Console.ExceptionHandling/AGENTS.md +++ b/Console.ExceptionHandling/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Console.Localization/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Console.Localization/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Console.Localization/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Console.Localization/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Console.Localization/.github/prompts/nano-add-api-client-configuration.prompt.md b/Console.Localization/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Console.Localization/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Console.Localization/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Console.Localization/AGENTS.md b/Console.Localization/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Console.Localization/AGENTS.md +++ b/Console.Localization/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Console.Logging.Log4Net/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Console.Logging.Log4Net/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Console.Logging.Log4Net/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Console.Logging.Log4Net/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Console.Logging.Log4Net/.github/prompts/nano-add-api-client-configuration.prompt.md b/Console.Logging.Log4Net/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Console.Logging.Log4Net/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Console.Logging.Log4Net/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Console.Logging.Log4Net/AGENTS.md b/Console.Logging.Log4Net/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Console.Logging.Log4Net/AGENTS.md +++ b/Console.Logging.Log4Net/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Console.Logging.Microsoft/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Console.Logging.Microsoft/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Console.Logging.Microsoft/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Console.Logging.Microsoft/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Console.Logging.Microsoft/.github/prompts/nano-add-api-client-configuration.prompt.md b/Console.Logging.Microsoft/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Console.Logging.Microsoft/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Console.Logging.Microsoft/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Console.Logging.Microsoft/AGENTS.md b/Console.Logging.Microsoft/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Console.Logging.Microsoft/AGENTS.md +++ b/Console.Logging.Microsoft/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Console.Logging.NLog/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Console.Logging.NLog/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Console.Logging.NLog/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Console.Logging.NLog/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Console.Logging.NLog/.github/prompts/nano-add-api-client-configuration.prompt.md b/Console.Logging.NLog/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Console.Logging.NLog/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Console.Logging.NLog/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Console.Logging.NLog/AGENTS.md b/Console.Logging.NLog/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Console.Logging.NLog/AGENTS.md +++ b/Console.Logging.NLog/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Console.Logging.Serilog/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Console.Logging.Serilog/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Console.Logging.Serilog/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Console.Logging.Serilog/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Console.Logging.Serilog/.github/prompts/nano-add-api-client-configuration.prompt.md b/Console.Logging.Serilog/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Console.Logging.Serilog/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Console.Logging.Serilog/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Console.Logging.Serilog/AGENTS.md b/Console.Logging.Serilog/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Console.Logging.Serilog/AGENTS.md +++ b/Console.Logging.Serilog/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Console.StartupTasks/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Console.StartupTasks/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Console.StartupTasks/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Console.StartupTasks/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Console.StartupTasks/.github/prompts/nano-add-api-client-configuration.prompt.md b/Console.StartupTasks/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Console.StartupTasks/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Console.StartupTasks/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Console.StartupTasks/AGENTS.md b/Console.StartupTasks/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Console.StartupTasks/AGENTS.md +++ b/Console.StartupTasks/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Console.Storage.Azure/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Console.Storage.Azure/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Console.Storage.Azure/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Console.Storage.Azure/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Console.Storage.Azure/.github/prompts/nano-add-api-client-configuration.prompt.md b/Console.Storage.Azure/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Console.Storage.Azure/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Console.Storage.Azure/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Console.Storage.Azure/AGENTS.md b/Console.Storage.Azure/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Console.Storage.Azure/AGENTS.md +++ b/Console.Storage.Azure/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Console.Storage.Local/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Console.Storage.Local/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Console.Storage.Local/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Console.Storage.Local/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Console.Storage.Local/.github/prompts/nano-add-api-client-configuration.prompt.md b/Console.Storage.Local/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Console.Storage.Local/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Console.Storage.Local/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Console.Storage.Local/AGENTS.md b/Console.Storage.Local/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Console.Storage.Local/AGENTS.md +++ b/Console.Storage.Local/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Console.Workers/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Console.Workers/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Console.Workers/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Console.Workers/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Console.Workers/.github/prompts/nano-add-api-client-configuration.prompt.md b/Console.Workers/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Console.Workers/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Console.Workers/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Console.Workers/AGENTS.md b/Console.Workers/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Console.Workers/AGENTS.md +++ b/Console.Workers/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Console._Blank/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Console._Blank/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Console._Blank/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Console._Blank/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Console._Blank/.github/prompts/nano-add-api-client-configuration.prompt.md b/Console._Blank/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Console._Blank/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Console._Blank/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Console._Blank/AGENTS.md b/Console._Blank/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Console._Blank/AGENTS.md +++ b/Console._Blank/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its diff --git a/Web._Blank/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Web._Blank/.claude/skills/nano-add-api-client-configuration/SKILL.md index 561ff02c..5d576fb8 100644 --- a/Web._Blank/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Web._Blank/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -151,7 +151,13 @@ Console app's own compose service has no `ports` of its own to worry about colli 3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` diff --git a/Web._Blank/.github/prompts/nano-add-api-client-configuration.prompt.md b/Web._Blank/.github/prompts/nano-add-api-client-configuration.prompt.md index ec64ef04..1a097451 100644 --- a/Web._Blank/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Web._Blank/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -5,24 +5,24 @@ description: Wire an existing Nano Api Client into this application - adds the A # Nano add API client configuration -Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target -service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +Wires an *already-defined* Api Client — a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project — into this consuming application: the `App:Apis` config entry and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` -section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +section first — it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and authentication forwarding in full; this skill does not repeat that, only how to consume a client from this app. -If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +If the client class doesn't exist yet, don't treat that as a choice to offer — treat it as a sign something may be wrong. Either the user is pointed at the wrong application (the client is -expected to already exist, defined on the *owning* service's side - check this isn't simply the +expected to already exist, defined on the *owning* service's side — check this isn't simply the wrong project before going further), or the owning service genuinely hasn't defined it yet, in which case that's `nano-add-api-client`'s job, in that other application's own project, not this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, -and ask which is true - don't invoke the other skill automatically, and don't proceed on the +and ask which is true — don't invoke the other skill automatically, and don't proceed on the assumption a missing class is just an item to create in passing. **No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +matches a key under `App:Apis` is auto-wired — but per AGENTS.md's own gotcha, a client that's never actually injected anywhere doesn't get registered at all. Add the config, then make sure something actually consumes it (a controller or worker constructor parameter), or none of this takes effect. @@ -31,7 +31,7 @@ takes effect. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the - user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined where expected, and ask two things: is this actually the right target/application to be wiring into right now, and if so, did they mean to create the client first (on the owning @@ -40,11 +40,11 @@ takes effect. 2. **How does this app reference the target's `.Models` project?** Check how any other Api Client in this project already references its target (`ProjectReference` for a same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target - the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention - AGENTS.md + target — the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention — AGENTS.md explicitly allows either here. If this is the first Api Client in the project, ask which applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't stop at determining which kind it should be. A missing reference here is a real, previously observed gap (this app referencing a target's Api Client with no corresponding `ProjectReference`/`PackageReference` at all, caught only when the build failed). @@ -55,19 +55,19 @@ takes effect. - **Private feed authentication is the user's responsibility, not something to work around.** If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and don't silently fall back to a different reference shape to dodge the failure. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key - matching the class name - if one's already there pointing at a different host/target, confirm + matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. 4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + inbound `HttpContext`, so they can't transparently forward a caller's JWT — a Console-hosted client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. ## appsettings.json (this app) -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` — the dictionary key must be the exact class name from step 1: ```json @@ -85,38 +85,38 @@ must be the exact class name from step 1: ``` - `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, + service name locally, if calling another app in the same compose network) — not sensitive, stays in the base file. - Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + `App:HealthCheck` is enabled (API/Web apps only) — same dead-config rule as every other provider's health check. - **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root + full `administrator` identity** on the target — not a lesser scope, the same as any human root login. That's exactly why it's worth using instead of just making the target endpoint anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated + root having acted — the right choice whenever the target also serves real authenticated end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - + access, the credential needs the same secret-handling rigor as anything else that powerful — don't hardcode it in the base `appsettings.json`; set the real value only in `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + **Don't create a new secret for this app** — `LogInRoot` only works if its credentials match the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target creates, never re-create or duplicate it with a new name. **But don't assume that secret - already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + already exists** — per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not something the target gets by default; it's an opt-in a human has to hand-wire on the target app specifically, which is a different repo this skill has no visibility into. Ask the user to confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ - `root-login-password`) wired up in Staging/Production before adding a reference to it here - + `root-login-password`) wired up in Staging/Production before adding a reference to it here — don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't - confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + confuse this with `Jwt.RootLogin` — see AGENTS.md's explicit warning distinguishing the two; they're on different apps, in different directions. ## Injecting the client -Inject the client class directly into whatever consumes it - a controller or worker constructor +Inject the client class directly into whatever consumes it — a controller or worker constructor parameter: ```csharp @@ -126,32 +126,38 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr } ``` -This is the step that actually makes the `App:Apis` entry take effect - without it, per the +This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. -## docker-compose.yml (local Development) - do this automatically, every time +## docker-compose.yml (local Development) — do this automatically, every time The target must actually run locally alongside this app, or `Host` in the config above resolves to nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` -section under Api Clients first - this is not an optional follow-up step, it's part of what +section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client needs its target runnable locally the same way an API/Web consumer does; the only difference is a Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a - service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, nothing to do here. 2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which - already reflects this) - determines whether the nested block gets `depends_on: [database, + already reflects this) — determines whether the nested block gets `depends_on: [database, eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file - only if not already present - one instance serves every nested dependency, never one per + only if not already present — one instance serves every nested dependency, never one per dependency. -3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested service's port, and `depends_on` wired both onto this app's own primary service (add the new - `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. **Also add + `env_file: [../../{TargetName}/.docker/.env]`, unconditionally** — every Nano app template + ships with an empty `.docker/.env` alongside its `docker-compose.yml` (see AGENTS.md's Local + Development section), so the file always exists even if the target has no secrets yet today. + Wiring the reference now means a secret added to the target later needs nothing done on this + app's side to pick it up — don't skip this because the target doesn't currently have any + secrets in its `.env`. 4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` @@ -159,22 +165,22 @@ Console app's own compose service has no `ports` of its own to worry about colli - If it already exists (this app already consumes at least one other Api Client), add the new target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) - don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. ## After making the change -- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), +- Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. -- Confirm the client is actually injected somewhere - if the request was just "add the client" with +- Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. - Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by - `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` producing an unreachable host for the new client. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file - and whether the target's `auth-root-login-secret` was +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let + a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. -- If restore failed due to private feed authentication, say so plainly and stop there - that's +- If restore failed due to private feed authentication, say so plainly and stop there — that's the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Web._Blank/AGENTS.md b/Web._Blank/AGENTS.md index 6cb764e0..b0433c70 100644 --- a/Web._Blank/AGENTS.md +++ b/Web._Blank/AGENTS.md @@ -51,6 +51,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/.env` | ✓ | ✓ | ✓ | Local-only secrets file loaded into this app's own compose service via `env_file` — always present, even empty, and gitignored. See [Local Development (docker-compose)](#local-development-docker-compose). | | `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | @@ -132,6 +133,10 @@ defaulting to `Development`. | `Staging` | Cloud | Cloud Kubernetes deployment. | | `Production` | Cloud | Cloud Kubernetes deployment. | +This default is why a nested dependency's compose service block (see [Local Development (docker-compose)](#local-development-docker-compose)) +doesn't need `ASPNETCORE_ENVIRONMENT` set explicitly — with nothing set, Nano itself resolves to `Development`, +the same as running locally any other way. + ### Configuration Standard .NET configuration providers, with precedence (later overrides earlier): @@ -408,6 +413,8 @@ svc.mytarget: WORKDIR /app COPY ./bin/publish/. . ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + env_file: + - ../../Svc.MyTarget/.docker/.env environment: ASPNETCORE_HTTP_PORTS: "" depends_on: # only if the target actually has a Data/Eventing provider configured @@ -423,6 +430,26 @@ dependency's `depends_on` to them if that dependency actually has a data/eventin own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone `docker-compose.yml`). +**Local-dev secrets (`.env`)**: `dotnet user-secrets`/`secrets.json` doesn't reach a Docker container — it reads +from a host-machine path outside any build context. The one exception is Visual Studio's own Docker Compose +debug launch, which auto-mounts the host's user-secrets folder, but only for the *primary* project being +debugged — never for a dependency's own nested service block, which is always built from a published binary +(see above), not through VS's debug tooling. So a nested dependency needs its secrets some other way. + +Every Nano app template ships with an empty `.docker/.env` alongside its `docker-compose.yml`, wired into that +app's own compose service via `env_file: [.env]` — present from day one, even with nothing in it yet. That file +is the single source of truth for that app's local-dev secrets (real external credentials — an emailing +provider's API key, not fixed shared dev values like the RabbitMq/MySql credentials below, which stay in +`appsettings.Development.json` as before). Name each key exactly as its `.NET` config path +(`Emailing__ApiKey`, matching `Emailing:ApiKey`), so `env_file:` needs no separate mapping step. + +`nano-add-api-client-configuration` always adds the `env_file:` reference shown above when nesting a target, +whether or not that target's `.env` currently has anything in it — since the file always exists, this means a +secret added to the target later needs nothing done on the consuming app's side to pick it up. Note that +`env_file:` loads every key in the referenced file into the container, with no way to select a subset — this is +fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that +whole service anyway. + **Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: 1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its From 5d14b0560d6bb5aba8e2e9ef9789de9536d3f3d8 Mon Sep 17 00:00:00 2001 From: vivet Date: Tue, 22 Sep 2026 10:25:31 +0200 Subject: [PATCH 05/13] Updated --- .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ .../SKILL.md | 178 ++++++++++++------ ...ano-add-authentication-microsoft.prompt.md | 178 ++++++++++++------ 184 files changed, 22264 insertions(+), 10488 deletions(-) diff --git a/Api.ApiClients.Audit/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.ApiClients.Audit/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Api.ApiClients.Audit/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.ApiClients.Audit/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.ApiClients.Audit/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.ApiClients.Audit/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Api.ApiClients.Audit/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.ApiClients.Audit/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.ApiClients.Entity/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.ApiClients.Entity/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Api.ApiClients.Entity/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.ApiClients.Entity/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.ApiClients.Entity/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.ApiClients.Entity/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Api.ApiClients.Entity/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.ApiClients.Entity/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.ApiClients.RootLogIn/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.ApiClients.RootLogIn/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Api.ApiClients.RootLogIn/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.ApiClients.RootLogIn/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.ApiClients.RootLogIn/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Api.ApiClients.RootLogIn/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.ApiClients/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.ApiClients/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Api.ApiClients/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.ApiClients/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.ApiClients/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.ApiClients/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Api.ApiClients/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.ApiClients/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Auth.External.Custom/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Auth.External.Custom/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Api.Auth.External.Custom/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Auth.External.Custom/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Auth.External.Custom/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Auth.External.Custom/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Api.Auth.External.Custom/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Auth.External.Custom/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Auth.External.Microsoft/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Auth.External.Microsoft/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Api.Auth.External.Microsoft/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Auth.External.Microsoft/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Auth.External.Microsoft/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Auth.External.Microsoft/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Api.Auth.External.Microsoft/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Auth.External.Microsoft/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Auth.RootLogin/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Auth.RootLogin/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Api.Auth.RootLogin/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Auth.RootLogin/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Auth.RootLogin/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Auth.RootLogin/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Api.Auth.RootLogin/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Auth.RootLogin/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Authorization/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Authorization/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Api.Authorization/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Authorization/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Authorization/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Authorization/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Api.Authorization/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Authorization/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.ContentNegotiation/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.ContentNegotiation/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Api.ContentNegotiation/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.ContentNegotiation/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.ContentNegotiation/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.ContentNegotiation/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Api.ContentNegotiation/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.ContentNegotiation/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Cookies/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Cookies/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Api.Cookies/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Cookies/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Cookies/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Cookies/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Api.Cookies/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Cookies/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.CustomConfigSection/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.CustomConfigSection/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Api.CustomConfigSection/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.CustomConfigSection/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.CustomConfigSection/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.CustomConfigSection/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Api.CustomConfigSection/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.CustomConfigSection/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.CustomMiddleware/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.CustomMiddleware/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Api.CustomMiddleware/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.CustomMiddleware/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.CustomMiddleware/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.CustomMiddleware/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Api.CustomMiddleware/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.CustomMiddleware/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.CustomService/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.CustomService/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Api.CustomService/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.CustomService/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.CustomService/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.CustomService/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Api.CustomService/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.CustomService/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.Audit/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.Audit/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Api.Data.Audit/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.Audit/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.Audit/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.Audit/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Api.Data.Audit/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.Audit/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.EntityEvents/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.EntityEvents/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Api.Data.EntityEvents/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.EntityEvents/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.EntityEvents/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.EntityEvents/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Api.Data.EntityEvents/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.EntityEvents/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.Identity/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.Identity/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Api.Data.Identity/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.Identity/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.Identity/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.Identity/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Api.Data.Identity/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.Identity/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.InMemory/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.InMemory/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Api.Data.InMemory/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.InMemory/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.InMemory/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.InMemory/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Api.Data.InMemory/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.InMemory/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.LazyLoading/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.LazyLoading/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Api.Data.LazyLoading/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.LazyLoading/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.LazyLoading/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.LazyLoading/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Api.Data.LazyLoading/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.LazyLoading/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.MySql.Collation/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.MySql.Collation/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Api.Data.MySql.Collation/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.MySql.Collation/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.MySql.Collation/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.MySql.Collation/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Api.Data.MySql.Collation/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.MySql.Collation/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.MySql.Mappings/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.MySql.Mappings/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Api.Data.MySql.Mappings/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.MySql.Mappings/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.MySql.Mappings/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.MySql.Mappings/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Api.Data.MySql.Mappings/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.MySql.Mappings/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.MySql.Spatial/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.MySql.Spatial/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Api.Data.MySql.Spatial/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.MySql.Spatial/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.MySql.Spatial/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.MySql.Spatial/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Api.Data.MySql.Spatial/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.MySql.Spatial/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.MySql.Views/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.MySql.Views/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Api.Data.MySql.Views/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.MySql.Views/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.MySql.Views/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.MySql.Views/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Api.Data.MySql.Views/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.MySql.Views/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.MySql/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.MySql/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Api.Data.MySql/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.MySql/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.MySql/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.MySql/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Api.Data.MySql/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.MySql/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.PostgreSQL/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.PostgreSQL/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Api.Data.PostgreSQL/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.PostgreSQL/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.PostgreSQL/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.PostgreSQL/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Api.Data.PostgreSQL/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.PostgreSQL/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.Repository.AutoSave/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.Repository.AutoSave/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Api.Data.Repository.AutoSave/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.Repository.AutoSave/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.Repository.AutoSave/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.Repository.AutoSave/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Api.Data.Repository.AutoSave/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.Repository.AutoSave/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.Repository.Includes/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.Repository.Includes/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Api.Data.Repository.Includes/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.Repository.Includes/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.Repository.Includes/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.Repository.Includes/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Api.Data.Repository.Includes/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.Repository.Includes/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.SoftDelete/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.SoftDelete/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Api.Data.SoftDelete/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.SoftDelete/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.SoftDelete/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.SoftDelete/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Api.Data.SoftDelete/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.SoftDelete/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.SqLite/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.SqLite/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Api.Data.SqLite/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.SqLite/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.SqLite/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.SqLite/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Api.Data.SqLite/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.SqLite/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.SqlServer/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.SqlServer/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Api.Data.SqlServer/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.SqlServer/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.SqlServer/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.SqlServer/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Api.Data.SqlServer/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.SqlServer/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.Triggers/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.Triggers/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Api.Data.Triggers/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.Triggers/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.Triggers/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.Triggers/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Api.Data.Triggers/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.Triggers/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Documentation.Csp/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Documentation.Csp/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Api.Documentation.Csp/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Documentation.Csp/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Documentation.Csp/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Documentation.Csp/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Api.Documentation.Csp/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Documentation.Csp/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Documentation/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Documentation/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Api.Documentation/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Documentation/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Documentation/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Documentation/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Api.Documentation/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Documentation/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.ErrorHandling/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.ErrorHandling/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Api.ErrorHandling/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.ErrorHandling/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.ErrorHandling/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.ErrorHandling/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Api.ErrorHandling/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.ErrorHandling/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Eventing.RabbitMq/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Eventing.RabbitMq/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Api.Eventing.RabbitMq/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Eventing.RabbitMq/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Eventing.RabbitMq/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Eventing.RabbitMq/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Api.Eventing.RabbitMq/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Eventing.RabbitMq/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.HealthChecks/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.HealthChecks/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Api.HealthChecks/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.HealthChecks/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.HealthChecks/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.HealthChecks/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Api.HealthChecks/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.HealthChecks/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Hosting.Http/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Hosting.Http/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Api.Hosting.Http/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Hosting.Http/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Hosting.Http/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Hosting.Http/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Api.Hosting.Http/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Hosting.Http/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Hosting.Https/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Hosting.Https/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Api.Hosting.Https/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Hosting.Https/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Hosting.Https/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Hosting.Https/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Api.Hosting.Https/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Hosting.Https/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Hosting.MultipartLimits/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Hosting.MultipartLimits/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Api.Hosting.MultipartLimits/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Hosting.MultipartLimits/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Hosting.MultipartLimits/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Hosting.MultipartLimits/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Api.Hosting.MultipartLimits/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Hosting.MultipartLimits/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Localization/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Localization/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Api.Localization/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Localization/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Localization/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Localization/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Api.Localization/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Localization/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Logging.Log4Net/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Logging.Log4Net/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Api.Logging.Log4Net/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Logging.Log4Net/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Logging.Log4Net/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Logging.Log4Net/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Api.Logging.Log4Net/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Logging.Log4Net/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Logging.Microsoft/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Logging.Microsoft/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Api.Logging.Microsoft/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Logging.Microsoft/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Logging.Microsoft/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Logging.Microsoft/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Api.Logging.Microsoft/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Logging.Microsoft/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Logging.NLog/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Logging.NLog/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Api.Logging.NLog/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Logging.NLog/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Logging.NLog/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Logging.NLog/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Api.Logging.NLog/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Logging.NLog/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Logging.Serilog/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Logging.Serilog/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Api.Logging.Serilog/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Logging.Serilog/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Logging.Serilog/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Logging.Serilog/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Api.Logging.Serilog/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Logging.Serilog/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Metrics/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Metrics/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Api.Metrics/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Metrics/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Metrics/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Metrics/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Api.Metrics/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Metrics/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.MultipartJson/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.MultipartJson/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Api.MultipartJson/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.MultipartJson/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.MultipartJson/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.MultipartJson/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Api.MultipartJson/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.MultipartJson/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.PolicyHeaders.Cors/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.PolicyHeaders.Cors/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Api.PolicyHeaders.Cors/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.PolicyHeaders.Cors/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.PolicyHeaders.Cors/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.PolicyHeaders.Cors/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Api.PolicyHeaders.Cors/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.PolicyHeaders.Cors/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.PolicyHeaders.Robots/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.PolicyHeaders.Robots/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Api.PolicyHeaders.Robots/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.PolicyHeaders.Robots/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.PolicyHeaders.Robots/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.PolicyHeaders.Robots/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Api.PolicyHeaders.Robots/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.PolicyHeaders.Robots/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.RequestTracing/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.RequestTracing/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Api.RequestTracing/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.RequestTracing/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.RequestTracing/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.RequestTracing/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Api.RequestTracing/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.RequestTracing/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.ResponseCache/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.ResponseCache/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Api.ResponseCache/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.ResponseCache/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.ResponseCache/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.ResponseCache/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Api.ResponseCache/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.ResponseCache/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.ResponseCompression/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.ResponseCompression/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Api.ResponseCompression/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.ResponseCompression/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.ResponseCompression/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.ResponseCompression/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Api.ResponseCompression/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.ResponseCompression/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Session/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Session/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Api.Session/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Session/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Session/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Session/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Api.Session/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Session/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.StartupTasks/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.StartupTasks/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Api.StartupTasks/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.StartupTasks/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.StartupTasks/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.StartupTasks/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Api.StartupTasks/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.StartupTasks/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.StaticFiles/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.StaticFiles/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Api.StaticFiles/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.StaticFiles/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.StaticFiles/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.StaticFiles/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Api.StaticFiles/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.StaticFiles/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Storage.Azure/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Storage.Azure/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Api.Storage.Azure/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Storage.Azure/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Storage.Azure/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Storage.Azure/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Api.Storage.Azure/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Storage.Azure/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Storage.Local/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Storage.Local/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Api.Storage.Local/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Storage.Local/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Storage.Local/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Storage.Local/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Api.Storage.Local/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Storage.Local/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.TimeZone/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.TimeZone/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Api.TimeZone/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.TimeZone/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.TimeZone/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.TimeZone/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Api.TimeZone/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.TimeZone/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Versioning/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Versioning/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Api.Versioning/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Versioning/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Versioning/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Versioning/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Api.Versioning/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Versioning/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.VirusScan/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.VirusScan/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Api.VirusScan/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.VirusScan/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.VirusScan/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.VirusScan/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Api.VirusScan/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.VirusScan/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api._Blank/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api._Blank/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Api._Blank/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api._Blank/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api._Blank/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api._Blank/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Api._Blank/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api._Blank/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Console.CustomConfigSection/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console.CustomConfigSection/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Console.CustomConfigSection/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console.CustomConfigSection/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Console.CustomConfigSection/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console.CustomConfigSection/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Console.CustomConfigSection/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console.CustomConfigSection/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Console.CustomService/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console.CustomService/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Console.CustomService/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console.CustomService/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Console.CustomService/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console.CustomService/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Console.CustomService/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console.CustomService/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Console.Data.InMemory/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console.Data.InMemory/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Console.Data.InMemory/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console.Data.InMemory/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Console.Data.InMemory/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console.Data.InMemory/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Console.Data.InMemory/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console.Data.InMemory/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Console.Data.MySql/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console.Data.MySql/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Console.Data.MySql/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console.Data.MySql/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Console.Data.MySql/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console.Data.MySql/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Console.Data.MySql/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console.Data.MySql/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Console.Data.PostgreSQL/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console.Data.PostgreSQL/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Console.Data.PostgreSQL/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console.Data.PostgreSQL/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Console.Data.PostgreSQL/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console.Data.PostgreSQL/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Console.Data.PostgreSQL/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console.Data.PostgreSQL/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Console.Data.SqLite/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console.Data.SqLite/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Console.Data.SqLite/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console.Data.SqLite/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Console.Data.SqLite/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console.Data.SqLite/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Console.Data.SqLite/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console.Data.SqLite/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Console.Data.SqlServer/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console.Data.SqlServer/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Console.Data.SqlServer/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console.Data.SqlServer/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Console.Data.SqlServer/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console.Data.SqlServer/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Console.Data.SqlServer/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console.Data.SqlServer/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Console.Eventing.RabbitMq/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console.Eventing.RabbitMq/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Console.Eventing.RabbitMq/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console.Eventing.RabbitMq/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Console.Eventing.RabbitMq/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console.Eventing.RabbitMq/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Console.Eventing.RabbitMq/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console.Eventing.RabbitMq/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Console.ExceptionHandling/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console.ExceptionHandling/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Console.ExceptionHandling/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console.ExceptionHandling/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Console.ExceptionHandling/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console.ExceptionHandling/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Console.ExceptionHandling/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console.ExceptionHandling/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Console.Localization/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console.Localization/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Console.Localization/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console.Localization/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Console.Localization/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console.Localization/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Console.Localization/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console.Localization/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Console.Logging.Log4Net/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console.Logging.Log4Net/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Console.Logging.Log4Net/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console.Logging.Log4Net/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Console.Logging.Log4Net/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console.Logging.Log4Net/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Console.Logging.Log4Net/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console.Logging.Log4Net/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Console.Logging.Microsoft/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console.Logging.Microsoft/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Console.Logging.Microsoft/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console.Logging.Microsoft/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Console.Logging.Microsoft/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console.Logging.Microsoft/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Console.Logging.Microsoft/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console.Logging.Microsoft/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Console.Logging.NLog/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console.Logging.NLog/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Console.Logging.NLog/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console.Logging.NLog/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Console.Logging.NLog/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console.Logging.NLog/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Console.Logging.NLog/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console.Logging.NLog/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Console.Logging.Serilog/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console.Logging.Serilog/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Console.Logging.Serilog/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console.Logging.Serilog/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Console.Logging.Serilog/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console.Logging.Serilog/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Console.Logging.Serilog/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console.Logging.Serilog/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Console.StartupTasks/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console.StartupTasks/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Console.StartupTasks/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console.StartupTasks/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Console.StartupTasks/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console.StartupTasks/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Console.StartupTasks/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console.StartupTasks/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Console.Storage.Azure/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console.Storage.Azure/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Console.Storage.Azure/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console.Storage.Azure/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Console.Storage.Azure/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console.Storage.Azure/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Console.Storage.Azure/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console.Storage.Azure/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Console.Storage.Local/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console.Storage.Local/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Console.Storage.Local/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console.Storage.Local/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Console.Storage.Local/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console.Storage.Local/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Console.Storage.Local/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console.Storage.Local/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Console.Workers/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console.Workers/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Console.Workers/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console.Workers/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Console.Workers/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console.Workers/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Console.Workers/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console.Workers/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Console._Blank/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console._Blank/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Console._Blank/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console._Blank/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Console._Blank/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console._Blank/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Console._Blank/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console._Blank/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Web._Blank/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Web._Blank/.claude/skills/nano-add-authentication-microsoft/SKILL.md index df9008de..013fa6c6 100644 --- a/Web._Blank/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Web._Blank/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-microsoft -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments — Development, Staging, and Production — there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client — don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match — include there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves — there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for — not something this skill pre-creates. No Graph API permission is needed beyond the -default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves — there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` — the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those environments. +## .docker/.env — placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above — don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here — a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development — one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically — each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** — it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed — not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch — just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and — if - Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally — this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file — see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above — not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Web._Blank/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Web._Blank/.github/prompts/nano-add-authentication-microsoft.prompt.md index 92a12e93..542ed534 100644 --- a/Web._Blank/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Web._Blank/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application, across all three environments — a one-time local app-registration script for Development, and a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret for Staging/Production. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- # Nano add Microsoft authentication @@ -17,6 +17,11 @@ yet, stop and point the user at `nano-add-authentication-jwt` first (it covers p transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under `ExternalLogins`). +**This always wires up all three environments - Development, Staging, and Production - there's no +partial-scope option to choose.** Development gets a one-time local app-registration script; +Staging/Production get the self-provisioning CI step. Don't ask the user which environments to +cover, only the questions below that actually vary per app. + **Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for creating their app credentials (created by hand through each provider's own developer console), so there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user @@ -33,13 +38,10 @@ convention for those the way this skill does for Microsoft. either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -3. **Development only, or does this need to actually work in Staging/Production?** If the user only - wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections - entirely rather than wiring up infrastructure nobody asked for yet. -4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for - Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't - guess a port/path. -5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's +3. **Is a redirect URI known?** Needed for the Azure AD app registration in every environment (the + Development script and the Staging/Production CI step each take their own). Ask if the request + doesn't name a client - don't guess a port/path. +4. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): @@ -83,43 +85,114 @@ The client-side authorize request's own `scope` parameter must match - include ` there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so this app's own config alone can't retroactively grant it. -**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key -pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked -Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to -whatever Entra ID app registration each individual developer creates for themselves - there's -nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure -Portal → Microsoft Entra ID → App -registrations → New registration → choose the sign-in audience decided above → Web redirect URI -matching whatever client will call this → Certificates & secrets → new client secret, copied -immediately since it's shown once → note the Application (client) ID) adds their own real values to -their own local `appsettings.Development.json` at that point, overriding just the fields they have -values for - not something this skill pre-creates. No Graph API permission is needed beyond the -default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and -whether a `refresh_token` is issued alongside it, not access to any resource. - -For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only -if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is -the same for every developer regardless of which tenant they created the registration in. +**`ExternalLogins.Microsoft` stays `null` in every `appsettings*.json` file, including +`appsettings.Development.json`.** Unlike the shared JWT Development key pair (a throwaway value +every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft +app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to whatever Entra ID app +registration each individual developer creates for themselves - there's nothing shared to pre-seed, +and nothing that belongs in a tracked config file at all. Per this app's own `.docker/.env` +convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets +go into `.docker/.env`, gitignored, not into `appsettings.Development.json` - the Local Development +script below produces exactly the three values that file needs. No Graph API permission is needed +beyond the default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the +`id_token` and whether a `refresh_token` is issued alongside it, not access to any resource. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those environments. +## .docker/.env - placeholder keys + +**Always add the three empty keys to this app's own `.docker/.env` in the same change that adds the +`appsettings.json` block above - don't leave this for later, and don't skip it just because the +values aren't known yet.** This mirrors the `null` placeholders in `appsettings.json`: the file +already exists (every app ships one, always present per this app's `.docker/.env` convention), so +the keys belong there now, empty, the same way `TenantId`/`ClientId`/`ClientSecret` are `null` in +`appsettings.json` rather than simply absent. + +``` +App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId= +App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret= +``` + +Leave the values empty here - a developer fills them in from the Local Development script's output +below, once they've run it. Key names are the `App:`-prefixed double-underscore config path, matching +this app's actual `appsettings.json` nesting under `App` and the same names the Kubernetes +`secretKeyRef` entries in the Kubernetes section below use. + +## Local Development - one-time app registration script + +Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by +hand, against their own Azure AD/Entra ID tenant. **Don't write this to a file in the app** - it's a +one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or +version. Show it directly in your response instead, styled like the `deploy.ps1` scripts under +`Nano.Azure/*` (env vars assigned as literals at the top, plain `az` calls below, no existence checks +or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): + +```powershell +$env:APP_DISPLAY_NAME = "{service-name}-app-development"; +$env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + +az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience {SignInAudience} ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + +$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + +$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name ($env:APP_DISPLAY_NAME + "-secret") ` + --years 1 ` + --query "password" -o tsv; + +Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; +Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; +Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; +``` + +- `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already + uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` + registration shared by Staging/Production, since this one belongs to a single developer's own + tenant, not a deployed environment. +- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill + them in once, from step 4's table above, matching whatever was decided for this app. No switch + statement here either, same reasoning as the CI step. +- `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in + their own local callback URL before running it. Don't fill in a guessed value. +- After running it, the developer fills the three values into the keys the `.docker/.env` step above + already placed - not `appsettings.Development.json`: + ``` + App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} + App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret} + ``` + `.env` is already gitignored and already loaded into the app's own compose service via `env_file`, + so nothing else needs wiring for these three keys to take effect. + ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: +This is the part that runs automatically, on every deploy. Add one workflow-level env var: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} - AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value -(the real deployed client's callback URL) rather than defaulting to a placeholder, and set -`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above -(e.g. `AzureADMyOrg`). +`vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's +callback URL) rather than defaulting to a placeholder. + +**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not +sourced from a workflow variable or derived by a runtime switch.** The audience was already decided +once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both +`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever +pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with +`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two +literals used in the Development script above. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -137,7 +210,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,17 +219,11 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --sign-in-audience {SignInAudience} ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) - { - "AzureADMyOrg" { $env:AZURE_TENANT_ID } - "AzureADMultipleOrgs" { "organizations" } - "AzureADandPersonalMicrosoftAccount" { "common" } - "PersonalMicrosoftAccount" { "consumers" } - }; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -185,12 +252,13 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` - directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences - need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before - making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to - `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for - the common case. +- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at + runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's + own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` + string instead, per the table in "Before making any change, determine" above. Since the audience + is decided once per app, not per run, there's no reason to carry it as a workflow variable or + re-derive `TenantId` from it with a runtime switch - just write the two matching literals + straight into this step (and the Development script above) when applying this skill. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -254,17 +322,13 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor ## After making the change -- Show the user every file touched, grouped by concern: appsettings per environment, and - if - Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, - the `deployment.yaml` additions, and the `.sln` entry. -- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ - Kubernetes half unasked. -- Remind the user to create their own Entra ID app registration for Development (the manual steps - above) before testing locally - this skill can't do that part for them, only Staging/Production is - scriptable. -- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure - `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret - stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. +- Show the user every file touched, grouped by concern: appsettings, the `.docker/.env` placeholder + keys, the workflow env var + new CI step, the new Kubernetes secret file, the `deployment.yaml` + additions, and the `.sln` entry. +- Include the Local Development script directly in your response (not written to a file - see + above), with a reminder to fill in their own redirect URI before running it, then add the three + printed values to this app's own `.docker/.env` using the `App__...` key names above - not + `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. From a5f0f0ad30d32790473e52815526d671a703f07d Mon Sep 17 00:00:00 2001 From: vivet Date: Wed, 23 Sep 2026 08:42:48 +0200 Subject: [PATCH 06/13] Updated --- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../.github/workflows/build-and-deploy.yml | 12 ++-- .../.kubernetes/auth-microsoft-secret.yaml | 2 +- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- .../SKILL.md | 55 +++++++++++-------- ...ano-add-authentication-microsoft.prompt.md | 55 +++++++++++-------- 186 files changed, 6081 insertions(+), 4053 deletions(-) diff --git a/Api.ApiClients.Audit/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.ApiClients.Audit/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Api.ApiClients.Audit/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.ApiClients.Audit/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.ApiClients.Audit/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.ApiClients.Audit/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Api.ApiClients.Audit/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.ApiClients.Audit/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.ApiClients.Entity/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.ApiClients.Entity/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Api.ApiClients.Entity/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.ApiClients.Entity/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.ApiClients.Entity/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.ApiClients.Entity/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Api.ApiClients.Entity/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.ApiClients.Entity/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.ApiClients.RootLogIn/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.ApiClients.RootLogIn/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Api.ApiClients.RootLogIn/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.ApiClients.RootLogIn/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.ApiClients.RootLogIn/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Api.ApiClients.RootLogIn/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.ApiClients/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.ApiClients/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Api.ApiClients/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.ApiClients/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.ApiClients/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.ApiClients/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Api.ApiClients/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.ApiClients/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Auth.External.Custom/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Auth.External.Custom/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Api.Auth.External.Custom/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Auth.External.Custom/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Auth.External.Custom/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Auth.External.Custom/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Api.Auth.External.Custom/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Auth.External.Custom/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Auth.External.Microsoft/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Auth.External.Microsoft/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Api.Auth.External.Microsoft/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Auth.External.Microsoft/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Auth.External.Microsoft/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Auth.External.Microsoft/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Api.Auth.External.Microsoft/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Auth.External.Microsoft/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Auth.External.Microsoft/.github/workflows/build-and-deploy.yml b/Api.Auth.External.Microsoft/.github/workflows/build-and-deploy.yml index 94d9efcc..fd7cb411 100644 --- a/Api.Auth.External.Microsoft/.github/workflows/build-and-deploy.yml +++ b/Api.Auth.External.Microsoft/.github/workflows/build-and-deploy.yml @@ -126,21 +126,24 @@ jobs: run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = "AzureADMyOrg"; + $env:AUTH_MICROSOFT_TENANT_ID = $env:AZURE_TENANT_ID; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; - if (-not $env:AUTH_MICROSOFT_CLIENT_ID) + if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience AzureADMyOrg ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; } - else + else { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -155,7 +158,7 @@ jobs: $staleCredentialIds = az ad app credential list --id $env:AUTH_MICROSOFT_CLIENT_ID --query "sort_by(@, &startDateTime)[:-3].keyId" -o tsv; - foreach ($keyId in ($staleCredentialIds -split "`n" | Where-Object { $_ })) + foreach ($keyId in ($staleCredentialIds -split "`n" | Where-Object { $_ })) { az ad app credential delete ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` @@ -164,6 +167,7 @@ jobs: echo "AUTH_MICROSOFT_CLIENT_ID=$env:AUTH_MICROSOFT_CLIENT_ID" >> $env:GITHUB_ENV; echo "AUTH_MICROSOFT_CLIENT_SECRET=$env:AUTH_MICROSOFT_CLIENT_SECRET" >> $env:GITHUB_ENV; + echo "AUTH_MICROSOFT_TENANT_ID=$env:AUTH_MICROSOFT_TENANT_ID" >> $env:GITHUB_ENV; - name: Kubernetes Deploy shell: pwsh diff --git a/Api.Auth.External.Microsoft/.kubernetes/auth-microsoft-secret.yaml b/Api.Auth.External.Microsoft/.kubernetes/auth-microsoft-secret.yaml index 48e4af89..b76bea3c 100644 --- a/Api.Auth.External.Microsoft/.kubernetes/auth-microsoft-secret.yaml +++ b/Api.Auth.External.Microsoft/.kubernetes/auth-microsoft-secret.yaml @@ -5,6 +5,6 @@ metadata: namespace: %KUBERNETES_NAMESPACE% type: Opaque stringData: - tenant-id: %AZURE_TENANT_ID% + tenant-id: %AUTH_MICROSOFT_TENANT_ID% client-id: %AUTH_MICROSOFT_CLIENT_ID% client-secret: %AUTH_MICROSOFT_CLIENT_SECRET% diff --git a/Api.Auth.RootLogin/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Auth.RootLogin/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Api.Auth.RootLogin/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Auth.RootLogin/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Auth.RootLogin/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Auth.RootLogin/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Api.Auth.RootLogin/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Auth.RootLogin/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Authorization/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Authorization/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Api.Authorization/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Authorization/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Authorization/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Authorization/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Api.Authorization/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Authorization/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.ContentNegotiation/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.ContentNegotiation/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Api.ContentNegotiation/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.ContentNegotiation/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.ContentNegotiation/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.ContentNegotiation/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Api.ContentNegotiation/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.ContentNegotiation/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Cookies/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Cookies/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Api.Cookies/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Cookies/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Cookies/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Cookies/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Api.Cookies/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Cookies/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.CustomConfigSection/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.CustomConfigSection/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Api.CustomConfigSection/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.CustomConfigSection/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.CustomConfigSection/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.CustomConfigSection/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Api.CustomConfigSection/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.CustomConfigSection/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.CustomMiddleware/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.CustomMiddleware/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Api.CustomMiddleware/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.CustomMiddleware/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.CustomMiddleware/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.CustomMiddleware/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Api.CustomMiddleware/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.CustomMiddleware/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.CustomService/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.CustomService/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Api.CustomService/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.CustomService/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.CustomService/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.CustomService/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Api.CustomService/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.CustomService/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Data.Audit/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.Audit/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Api.Data.Audit/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.Audit/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Data.Audit/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.Audit/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Api.Data.Audit/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.Audit/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Data.EntityEvents/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.EntityEvents/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Api.Data.EntityEvents/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.EntityEvents/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Data.EntityEvents/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.EntityEvents/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Api.Data.EntityEvents/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.EntityEvents/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Data.Identity/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.Identity/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Api.Data.Identity/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.Identity/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Data.Identity/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.Identity/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Api.Data.Identity/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.Identity/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Data.InMemory/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.InMemory/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Api.Data.InMemory/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.InMemory/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Data.InMemory/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.InMemory/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Api.Data.InMemory/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.InMemory/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Data.LazyLoading/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.LazyLoading/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Api.Data.LazyLoading/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.LazyLoading/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Data.LazyLoading/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.LazyLoading/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Api.Data.LazyLoading/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.LazyLoading/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Data.MySql.Collation/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.MySql.Collation/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Api.Data.MySql.Collation/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.MySql.Collation/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Data.MySql.Collation/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.MySql.Collation/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Api.Data.MySql.Collation/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.MySql.Collation/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Data.MySql.Mappings/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.MySql.Mappings/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Api.Data.MySql.Mappings/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.MySql.Mappings/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Data.MySql.Mappings/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.MySql.Mappings/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Api.Data.MySql.Mappings/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.MySql.Mappings/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Data.MySql.Spatial/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.MySql.Spatial/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Api.Data.MySql.Spatial/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.MySql.Spatial/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Data.MySql.Spatial/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.MySql.Spatial/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Api.Data.MySql.Spatial/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.MySql.Spatial/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Data.MySql.Views/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.MySql.Views/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Api.Data.MySql.Views/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.MySql.Views/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Data.MySql.Views/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.MySql.Views/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Api.Data.MySql.Views/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.MySql.Views/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Data.MySql/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.MySql/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Api.Data.MySql/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.MySql/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Data.MySql/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.MySql/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Api.Data.MySql/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.MySql/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Data.PostgreSQL/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.PostgreSQL/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Api.Data.PostgreSQL/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.PostgreSQL/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Data.PostgreSQL/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.PostgreSQL/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Api.Data.PostgreSQL/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.PostgreSQL/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Data.Repository.AutoSave/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.Repository.AutoSave/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Api.Data.Repository.AutoSave/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.Repository.AutoSave/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Data.Repository.AutoSave/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.Repository.AutoSave/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Api.Data.Repository.AutoSave/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.Repository.AutoSave/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Data.Repository.Includes/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.Repository.Includes/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Api.Data.Repository.Includes/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.Repository.Includes/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Data.Repository.Includes/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.Repository.Includes/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Api.Data.Repository.Includes/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.Repository.Includes/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Data.SoftDelete/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.SoftDelete/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Api.Data.SoftDelete/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.SoftDelete/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Data.SoftDelete/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.SoftDelete/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Api.Data.SoftDelete/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.SoftDelete/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Data.SqLite/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.SqLite/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Api.Data.SqLite/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.SqLite/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Data.SqLite/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.SqLite/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Api.Data.SqLite/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.SqLite/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Data.SqlServer/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.SqlServer/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Api.Data.SqlServer/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.SqlServer/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Data.SqlServer/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.SqlServer/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Api.Data.SqlServer/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.SqlServer/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Data.Triggers/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.Triggers/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Api.Data.Triggers/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.Triggers/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Data.Triggers/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.Triggers/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Api.Data.Triggers/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.Triggers/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Documentation.Csp/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Documentation.Csp/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Api.Documentation.Csp/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Documentation.Csp/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Documentation.Csp/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Documentation.Csp/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Api.Documentation.Csp/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Documentation.Csp/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Documentation/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Documentation/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Api.Documentation/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Documentation/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Documentation/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Documentation/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Api.Documentation/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Documentation/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.ErrorHandling/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.ErrorHandling/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Api.ErrorHandling/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.ErrorHandling/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.ErrorHandling/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.ErrorHandling/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Api.ErrorHandling/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.ErrorHandling/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Eventing.RabbitMq/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Eventing.RabbitMq/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Api.Eventing.RabbitMq/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Eventing.RabbitMq/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Eventing.RabbitMq/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Eventing.RabbitMq/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Api.Eventing.RabbitMq/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Eventing.RabbitMq/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.HealthChecks/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.HealthChecks/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Api.HealthChecks/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.HealthChecks/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.HealthChecks/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.HealthChecks/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Api.HealthChecks/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.HealthChecks/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Hosting.Http/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Hosting.Http/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Api.Hosting.Http/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Hosting.Http/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Hosting.Http/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Hosting.Http/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Api.Hosting.Http/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Hosting.Http/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Hosting.Https/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Hosting.Https/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Api.Hosting.Https/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Hosting.Https/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Hosting.Https/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Hosting.Https/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Api.Hosting.Https/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Hosting.Https/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Hosting.MultipartLimits/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Hosting.MultipartLimits/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Api.Hosting.MultipartLimits/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Hosting.MultipartLimits/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Hosting.MultipartLimits/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Hosting.MultipartLimits/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Api.Hosting.MultipartLimits/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Hosting.MultipartLimits/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Localization/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Localization/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Api.Localization/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Localization/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Localization/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Localization/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Api.Localization/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Localization/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Logging.Log4Net/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Logging.Log4Net/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Api.Logging.Log4Net/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Logging.Log4Net/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Logging.Log4Net/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Logging.Log4Net/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Api.Logging.Log4Net/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Logging.Log4Net/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Logging.Microsoft/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Logging.Microsoft/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Api.Logging.Microsoft/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Logging.Microsoft/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Logging.Microsoft/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Logging.Microsoft/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Api.Logging.Microsoft/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Logging.Microsoft/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Logging.NLog/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Logging.NLog/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Api.Logging.NLog/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Logging.NLog/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Logging.NLog/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Logging.NLog/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Api.Logging.NLog/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Logging.NLog/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Logging.Serilog/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Logging.Serilog/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Api.Logging.Serilog/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Logging.Serilog/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Logging.Serilog/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Logging.Serilog/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Api.Logging.Serilog/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Logging.Serilog/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Metrics/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Metrics/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Api.Metrics/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Metrics/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Metrics/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Metrics/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Api.Metrics/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Metrics/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.MultipartJson/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.MultipartJson/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Api.MultipartJson/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.MultipartJson/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.MultipartJson/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.MultipartJson/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Api.MultipartJson/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.MultipartJson/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.PolicyHeaders.Cors/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.PolicyHeaders.Cors/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Api.PolicyHeaders.Cors/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.PolicyHeaders.Cors/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.PolicyHeaders.Cors/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.PolicyHeaders.Cors/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Api.PolicyHeaders.Cors/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.PolicyHeaders.Cors/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.PolicyHeaders.Robots/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.PolicyHeaders.Robots/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Api.PolicyHeaders.Robots/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.PolicyHeaders.Robots/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.PolicyHeaders.Robots/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.PolicyHeaders.Robots/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Api.PolicyHeaders.Robots/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.PolicyHeaders.Robots/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.RequestTracing/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.RequestTracing/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Api.RequestTracing/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.RequestTracing/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.RequestTracing/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.RequestTracing/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Api.RequestTracing/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.RequestTracing/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.ResponseCache/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.ResponseCache/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Api.ResponseCache/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.ResponseCache/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.ResponseCache/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.ResponseCache/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Api.ResponseCache/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.ResponseCache/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.ResponseCompression/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.ResponseCompression/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Api.ResponseCompression/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.ResponseCompression/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.ResponseCompression/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.ResponseCompression/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Api.ResponseCompression/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.ResponseCompression/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Session/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Session/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Api.Session/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Session/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Session/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Session/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Api.Session/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Session/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.StartupTasks/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.StartupTasks/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Api.StartupTasks/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.StartupTasks/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.StartupTasks/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.StartupTasks/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Api.StartupTasks/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.StartupTasks/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.StaticFiles/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.StaticFiles/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Api.StaticFiles/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.StaticFiles/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.StaticFiles/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.StaticFiles/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Api.StaticFiles/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.StaticFiles/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Storage.Azure/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Storage.Azure/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Api.Storage.Azure/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Storage.Azure/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Storage.Azure/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Storage.Azure/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Api.Storage.Azure/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Storage.Azure/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Storage.Local/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Storage.Local/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Api.Storage.Local/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Storage.Local/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Storage.Local/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Storage.Local/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Api.Storage.Local/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Storage.Local/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.TimeZone/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.TimeZone/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Api.TimeZone/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.TimeZone/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.TimeZone/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.TimeZone/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Api.TimeZone/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.TimeZone/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Versioning/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Versioning/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Api.Versioning/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Versioning/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.Versioning/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Versioning/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Api.Versioning/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Versioning/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.VirusScan/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.VirusScan/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Api.VirusScan/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.VirusScan/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api.VirusScan/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.VirusScan/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Api.VirusScan/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.VirusScan/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api._Blank/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api._Blank/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Api._Blank/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api._Blank/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Api._Blank/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api._Blank/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Api._Blank/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api._Blank/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Console.CustomConfigSection/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console.CustomConfigSection/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Console.CustomConfigSection/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console.CustomConfigSection/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Console.CustomConfigSection/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console.CustomConfigSection/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Console.CustomConfigSection/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console.CustomConfigSection/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Console.CustomService/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console.CustomService/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Console.CustomService/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console.CustomService/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Console.CustomService/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console.CustomService/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Console.CustomService/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console.CustomService/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Console.Data.InMemory/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console.Data.InMemory/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Console.Data.InMemory/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console.Data.InMemory/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Console.Data.InMemory/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console.Data.InMemory/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Console.Data.InMemory/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console.Data.InMemory/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Console.Data.MySql/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console.Data.MySql/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Console.Data.MySql/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console.Data.MySql/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Console.Data.MySql/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console.Data.MySql/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Console.Data.MySql/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console.Data.MySql/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Console.Data.PostgreSQL/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console.Data.PostgreSQL/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Console.Data.PostgreSQL/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console.Data.PostgreSQL/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Console.Data.PostgreSQL/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console.Data.PostgreSQL/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Console.Data.PostgreSQL/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console.Data.PostgreSQL/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Console.Data.SqLite/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console.Data.SqLite/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Console.Data.SqLite/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console.Data.SqLite/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Console.Data.SqLite/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console.Data.SqLite/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Console.Data.SqLite/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console.Data.SqLite/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Console.Data.SqlServer/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console.Data.SqlServer/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Console.Data.SqlServer/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console.Data.SqlServer/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Console.Data.SqlServer/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console.Data.SqlServer/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Console.Data.SqlServer/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console.Data.SqlServer/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Console.Eventing.RabbitMq/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console.Eventing.RabbitMq/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Console.Eventing.RabbitMq/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console.Eventing.RabbitMq/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Console.Eventing.RabbitMq/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console.Eventing.RabbitMq/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Console.Eventing.RabbitMq/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console.Eventing.RabbitMq/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Console.ExceptionHandling/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console.ExceptionHandling/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Console.ExceptionHandling/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console.ExceptionHandling/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Console.ExceptionHandling/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console.ExceptionHandling/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Console.ExceptionHandling/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console.ExceptionHandling/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Console.Localization/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console.Localization/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Console.Localization/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console.Localization/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Console.Localization/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console.Localization/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Console.Localization/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console.Localization/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Console.Logging.Log4Net/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console.Logging.Log4Net/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Console.Logging.Log4Net/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console.Logging.Log4Net/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Console.Logging.Log4Net/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console.Logging.Log4Net/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Console.Logging.Log4Net/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console.Logging.Log4Net/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Console.Logging.Microsoft/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console.Logging.Microsoft/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Console.Logging.Microsoft/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console.Logging.Microsoft/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Console.Logging.Microsoft/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console.Logging.Microsoft/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Console.Logging.Microsoft/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console.Logging.Microsoft/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Console.Logging.NLog/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console.Logging.NLog/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Console.Logging.NLog/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console.Logging.NLog/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Console.Logging.NLog/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console.Logging.NLog/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Console.Logging.NLog/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console.Logging.NLog/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Console.Logging.Serilog/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console.Logging.Serilog/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Console.Logging.Serilog/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console.Logging.Serilog/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Console.Logging.Serilog/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console.Logging.Serilog/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Console.Logging.Serilog/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console.Logging.Serilog/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Console.StartupTasks/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console.StartupTasks/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Console.StartupTasks/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console.StartupTasks/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Console.StartupTasks/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console.StartupTasks/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Console.StartupTasks/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console.StartupTasks/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Console.Storage.Azure/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console.Storage.Azure/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Console.Storage.Azure/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console.Storage.Azure/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Console.Storage.Azure/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console.Storage.Azure/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Console.Storage.Azure/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console.Storage.Azure/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Console.Storage.Local/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console.Storage.Local/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Console.Storage.Local/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console.Storage.Local/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Console.Storage.Local/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console.Storage.Local/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Console.Storage.Local/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console.Storage.Local/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Console.Workers/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console.Workers/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Console.Workers/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console.Workers/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Console.Workers/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console.Workers/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Console.Workers/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console.Workers/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Console._Blank/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console._Blank/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Console._Blank/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console._Blank/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Console._Blank/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console._Blank/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Console._Blank/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console._Blank/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Web._Blank/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Web._Blank/.claude/skills/nano-add-authentication-microsoft/SKILL.md index 013fa6c6..d370556a 100644 --- a/Web._Blank/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Web._Blank/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -134,11 +134,12 @@ or `if`/`else` branching — this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended — kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + — fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` — it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above — there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) — the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above — there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) — the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** — the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) — the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch — just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch — just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** — in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with — nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps diff --git a/Web._Blank/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Web._Blank/.github/prompts/nano-add-authentication-microsoft.prompt.md index 542ed534..bf456a38 100644 --- a/Web._Blank/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Web._Blank/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -134,11 +134,12 @@ or `if`/`else` branching - this runs once, by a human, not repeatedly by CI): ```powershell $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; +$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,9 +160,9 @@ Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; uses), with `-app-development` appended - kept distinct from the CI-managed `{service-name}-app` registration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment. -- `{SignInAudience}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill - them in once, from step 4's table above, matching whatever was decided for this app. No switch - statement here either, same reasoning as the CI step. +- `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below + - fill them in once, from step 4's table above, matching whatever was decided for this app. No + switch statement here either, same reasoning as the CI step. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -186,13 +187,16 @@ env: `vars`, not `secrets` - it isn't sensitive. Ask the user for its value (the real deployed client's callback URL) rather than defaulting to a placeholder. -**`--sign-in-audience` and the resulting `TenantId` are hardcoded directly into the step below, not -sourced from a workflow variable or derived by a runtime switch.** The audience was already decided -once, at authoring time, in step 4 above - there's nothing left to branch on at runtime. Fill in both -`--sign-in-audience` occurrences and the `$env:AUTH_MICROSOFT_TENANT_ID` literal below with whichever -pair was chosen, straight from step 4's table (e.g. `AzureADMyOrg` paired with -`$env:AZURE_TENANT_ID`, or `AzureADMultipleOrgs` paired with `"organizations"`) - the same two -literals used in the Development script above. +**`--sign-in-audience` and `TenantId` are each assigned once, as their own `$env:` variables at the +top of the step, not sourced from a workflow variable and not derived by a runtime switch.** The +audience was already decided once, at authoring time, in step 4 above - there's nothing left to +branch on at runtime. Fill in `$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `$env:AUTH_MICROSOFT_TENANT_ID` +below with whichever pair was chosen, straight from step 4's table (e.g. `"AzureADMyOrg"` paired with +`$env:AZURE_TENANT_ID`, or `"AzureADMultipleOrgs"` paired with `"organizations"`) - the same two +literals used in the Development script above. **`$env:AUTH_MICROSOFT_TENANT_ID` is always set this +way, even for `AzureADMyOrg`** - the Kubernetes secret and `$env:GITHUB_ENV` output below always read +`$env:AUTH_MICROSOFT_TENANT_ID`, never `$env:AZURE_TENANT_ID` directly, so the two stay +interchangeable regardless of which audience a given app uses. Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -204,13 +208,15 @@ Kubernetes step): run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; + $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -219,12 +225,10 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` - --sign-in-audience {SignInAudience} ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } - $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; - $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -252,13 +256,20 @@ What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync. -- **`AUTH_MICROSOFT_TENANT_ID` is a literal filled in once, at authoring time, not computed at - runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's - own tenant) - the other three audiences need the literal `organizations`/`common`/`consumers` - string instead, per the table in "Before making any change, determine" above. Since the audience - is decided once per app, not per run, there's no reason to carry it as a workflow variable or - re-derive `TenantId` from it with a runtime switch - just write the two matching literals - straight into this step (and the Development script above) when applying this skill. +- **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, + at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID + (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other + three audiences need the literal `organizations`/`common`/`consumers` string instead, per the table + in "Before making any change, determine" above. Since the audience is decided once per app, not per + run, there's no reason to carry it as a workflow variable or re-derive `TenantId` from it with a + runtime switch - just assign both as `$env:` variables once, at the top of the step, when applying + this skill (and again in the Development script above). +- **Always reference `$env:AUTH_MICROSOFT_TENANT_ID` downstream, never `$env:AZURE_TENANT_ID` + directly** - in the Kubernetes secret's `stringData` below and in the `$env:GITHUB_ENV` output. + Even when the two happen to hold the same value (the `AzureADMyOrg` case), keeping every downstream + reference pointed at `AUTH_MICROSOFT_TENANT_ID` means the app works identically regardless of which + audience it was configured with - nothing downstream needs to know or care which literal was + chosen. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps From 948ed5e34702da9e85ed84b3d00bd8608548f8d1 Mon Sep 17 00:00:00 2001 From: vivet Date: Wed, 23 Sep 2026 10:59:00 +0200 Subject: [PATCH 07/13] Updated --- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../.github/workflows/build-and-deploy.yml | 2 +- .../Api.Auth.External.Microsoft.csproj | 1 - Api.Auth.External.Microsoft/README.md | 2 +- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- .../nano-add-authentication-microsoft/SKILL.md | 12 ++++-------- .../nano-add-authentication-microsoft.prompt.md | 12 ++++-------- 187 files changed, 738 insertions(+), 1475 deletions(-) diff --git a/Api.ApiClients.Audit/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.ApiClients.Audit/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Api.ApiClients.Audit/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.ApiClients.Audit/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.ApiClients.Audit/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.ApiClients.Audit/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Api.ApiClients.Audit/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.ApiClients.Audit/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.ApiClients.Entity/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.ApiClients.Entity/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Api.ApiClients.Entity/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.ApiClients.Entity/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.ApiClients.Entity/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.ApiClients.Entity/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Api.ApiClients.Entity/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.ApiClients.Entity/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.ApiClients.RootLogIn/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.ApiClients.RootLogIn/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Api.ApiClients.RootLogIn/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.ApiClients.RootLogIn/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.ApiClients.RootLogIn/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Api.ApiClients.RootLogIn/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.ApiClients/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.ApiClients/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Api.ApiClients/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.ApiClients/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.ApiClients/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.ApiClients/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Api.ApiClients/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.ApiClients/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Auth.External.Custom/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Auth.External.Custom/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Api.Auth.External.Custom/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Auth.External.Custom/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Auth.External.Custom/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Auth.External.Custom/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Api.Auth.External.Custom/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Auth.External.Custom/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Auth.External.Microsoft/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Auth.External.Microsoft/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Api.Auth.External.Microsoft/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Auth.External.Microsoft/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Auth.External.Microsoft/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Auth.External.Microsoft/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Api.Auth.External.Microsoft/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Auth.External.Microsoft/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Auth.External.Microsoft/.github/workflows/build-and-deploy.yml b/Api.Auth.External.Microsoft/.github/workflows/build-and-deploy.yml index fd7cb411..e47fd126 100644 --- a/Api.Auth.External.Microsoft/.github/workflows/build-and-deploy.yml +++ b/Api.Auth.External.Microsoft/.github/workflows/build-and-deploy.yml @@ -151,7 +151,7 @@ jobs: --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; diff --git a/Api.Auth.External.Microsoft/Api.Auth.External.Microsoft/Api.Auth.External.Microsoft.csproj b/Api.Auth.External.Microsoft/Api.Auth.External.Microsoft/Api.Auth.External.Microsoft.csproj index cdc6eb91..8e84327d 100644 --- a/Api.Auth.External.Microsoft/Api.Auth.External.Microsoft/Api.Auth.External.Microsoft.csproj +++ b/Api.Auth.External.Microsoft/Api.Auth.External.Microsoft/Api.Auth.External.Microsoft.csproj @@ -20,7 +20,6 @@ true Linux ..\docker-compose.dcproj - a997c4ca-1e0e-46c1-8df4-732949603d7b diff --git a/Api.Auth.External.Microsoft/README.md b/Api.Auth.External.Microsoft/README.md index 8995990c..1b92832e 100644 --- a/Api.Auth.External.Microsoft/README.md +++ b/Api.Auth.External.Microsoft/README.md @@ -238,7 +238,7 @@ env: --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; diff --git a/Api.Auth.RootLogin/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Auth.RootLogin/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Api.Auth.RootLogin/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Auth.RootLogin/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Auth.RootLogin/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Auth.RootLogin/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Api.Auth.RootLogin/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Auth.RootLogin/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Authorization/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Authorization/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Api.Authorization/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Authorization/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Authorization/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Authorization/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Api.Authorization/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Authorization/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.ContentNegotiation/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.ContentNegotiation/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Api.ContentNegotiation/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.ContentNegotiation/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.ContentNegotiation/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.ContentNegotiation/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Api.ContentNegotiation/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.ContentNegotiation/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Cookies/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Cookies/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Api.Cookies/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Cookies/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Cookies/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Cookies/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Api.Cookies/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Cookies/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.CustomConfigSection/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.CustomConfigSection/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Api.CustomConfigSection/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.CustomConfigSection/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.CustomConfigSection/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.CustomConfigSection/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Api.CustomConfigSection/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.CustomConfigSection/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.CustomMiddleware/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.CustomMiddleware/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Api.CustomMiddleware/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.CustomMiddleware/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.CustomMiddleware/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.CustomMiddleware/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Api.CustomMiddleware/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.CustomMiddleware/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.CustomService/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.CustomService/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Api.CustomService/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.CustomService/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.CustomService/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.CustomService/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Api.CustomService/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.CustomService/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.Audit/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.Audit/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Api.Data.Audit/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.Audit/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.Audit/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.Audit/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Api.Data.Audit/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.Audit/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.EntityEvents/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.EntityEvents/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Api.Data.EntityEvents/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.EntityEvents/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.EntityEvents/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.EntityEvents/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Api.Data.EntityEvents/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.EntityEvents/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.Identity/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.Identity/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Api.Data.Identity/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.Identity/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.Identity/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.Identity/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Api.Data.Identity/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.Identity/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.InMemory/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.InMemory/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Api.Data.InMemory/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.InMemory/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.InMemory/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.InMemory/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Api.Data.InMemory/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.InMemory/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.LazyLoading/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.LazyLoading/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Api.Data.LazyLoading/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.LazyLoading/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.LazyLoading/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.LazyLoading/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Api.Data.LazyLoading/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.LazyLoading/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.MySql.Collation/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.MySql.Collation/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Api.Data.MySql.Collation/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.MySql.Collation/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.MySql.Collation/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.MySql.Collation/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Api.Data.MySql.Collation/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.MySql.Collation/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.MySql.Mappings/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.MySql.Mappings/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Api.Data.MySql.Mappings/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.MySql.Mappings/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.MySql.Mappings/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.MySql.Mappings/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Api.Data.MySql.Mappings/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.MySql.Mappings/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.MySql.Spatial/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.MySql.Spatial/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Api.Data.MySql.Spatial/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.MySql.Spatial/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.MySql.Spatial/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.MySql.Spatial/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Api.Data.MySql.Spatial/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.MySql.Spatial/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.MySql.Views/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.MySql.Views/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Api.Data.MySql.Views/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.MySql.Views/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.MySql.Views/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.MySql.Views/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Api.Data.MySql.Views/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.MySql.Views/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.MySql/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.MySql/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Api.Data.MySql/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.MySql/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.MySql/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.MySql/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Api.Data.MySql/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.MySql/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.PostgreSQL/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.PostgreSQL/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Api.Data.PostgreSQL/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.PostgreSQL/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.PostgreSQL/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.PostgreSQL/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Api.Data.PostgreSQL/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.PostgreSQL/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.Repository.AutoSave/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.Repository.AutoSave/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Api.Data.Repository.AutoSave/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.Repository.AutoSave/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.Repository.AutoSave/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.Repository.AutoSave/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Api.Data.Repository.AutoSave/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.Repository.AutoSave/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.Repository.Includes/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.Repository.Includes/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Api.Data.Repository.Includes/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.Repository.Includes/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.Repository.Includes/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.Repository.Includes/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Api.Data.Repository.Includes/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.Repository.Includes/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.SoftDelete/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.SoftDelete/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Api.Data.SoftDelete/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.SoftDelete/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.SoftDelete/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.SoftDelete/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Api.Data.SoftDelete/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.SoftDelete/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.SqLite/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.SqLite/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Api.Data.SqLite/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.SqLite/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.SqLite/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.SqLite/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Api.Data.SqLite/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.SqLite/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.SqlServer/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.SqlServer/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Api.Data.SqlServer/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.SqlServer/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.SqlServer/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.SqlServer/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Api.Data.SqlServer/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.SqlServer/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.Triggers/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.Triggers/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Api.Data.Triggers/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.Triggers/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Data.Triggers/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.Triggers/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Api.Data.Triggers/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.Triggers/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Documentation.Csp/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Documentation.Csp/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Api.Documentation.Csp/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Documentation.Csp/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Documentation.Csp/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Documentation.Csp/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Api.Documentation.Csp/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Documentation.Csp/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Documentation/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Documentation/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Api.Documentation/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Documentation/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Documentation/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Documentation/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Api.Documentation/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Documentation/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.ErrorHandling/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.ErrorHandling/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Api.ErrorHandling/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.ErrorHandling/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.ErrorHandling/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.ErrorHandling/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Api.ErrorHandling/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.ErrorHandling/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Eventing.RabbitMq/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Eventing.RabbitMq/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Api.Eventing.RabbitMq/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Eventing.RabbitMq/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Eventing.RabbitMq/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Eventing.RabbitMq/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Api.Eventing.RabbitMq/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Eventing.RabbitMq/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.HealthChecks/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.HealthChecks/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Api.HealthChecks/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.HealthChecks/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.HealthChecks/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.HealthChecks/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Api.HealthChecks/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.HealthChecks/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Hosting.Http/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Hosting.Http/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Api.Hosting.Http/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Hosting.Http/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Hosting.Http/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Hosting.Http/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Api.Hosting.Http/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Hosting.Http/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Hosting.Https/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Hosting.Https/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Api.Hosting.Https/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Hosting.Https/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Hosting.Https/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Hosting.Https/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Api.Hosting.Https/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Hosting.Https/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Hosting.MultipartLimits/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Hosting.MultipartLimits/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Api.Hosting.MultipartLimits/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Hosting.MultipartLimits/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Hosting.MultipartLimits/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Hosting.MultipartLimits/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Api.Hosting.MultipartLimits/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Hosting.MultipartLimits/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Localization/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Localization/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Api.Localization/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Localization/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Localization/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Localization/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Api.Localization/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Localization/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Logging.Log4Net/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Logging.Log4Net/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Api.Logging.Log4Net/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Logging.Log4Net/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Logging.Log4Net/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Logging.Log4Net/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Api.Logging.Log4Net/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Logging.Log4Net/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Logging.Microsoft/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Logging.Microsoft/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Api.Logging.Microsoft/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Logging.Microsoft/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Logging.Microsoft/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Logging.Microsoft/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Api.Logging.Microsoft/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Logging.Microsoft/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Logging.NLog/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Logging.NLog/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Api.Logging.NLog/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Logging.NLog/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Logging.NLog/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Logging.NLog/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Api.Logging.NLog/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Logging.NLog/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Logging.Serilog/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Logging.Serilog/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Api.Logging.Serilog/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Logging.Serilog/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Logging.Serilog/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Logging.Serilog/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Api.Logging.Serilog/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Logging.Serilog/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Metrics/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Metrics/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Api.Metrics/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Metrics/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Metrics/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Metrics/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Api.Metrics/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Metrics/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.MultipartJson/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.MultipartJson/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Api.MultipartJson/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.MultipartJson/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.MultipartJson/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.MultipartJson/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Api.MultipartJson/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.MultipartJson/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.PolicyHeaders.Cors/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.PolicyHeaders.Cors/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Api.PolicyHeaders.Cors/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.PolicyHeaders.Cors/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.PolicyHeaders.Cors/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.PolicyHeaders.Cors/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Api.PolicyHeaders.Cors/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.PolicyHeaders.Cors/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.PolicyHeaders.Robots/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.PolicyHeaders.Robots/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Api.PolicyHeaders.Robots/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.PolicyHeaders.Robots/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.PolicyHeaders.Robots/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.PolicyHeaders.Robots/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Api.PolicyHeaders.Robots/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.PolicyHeaders.Robots/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.RequestTracing/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.RequestTracing/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Api.RequestTracing/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.RequestTracing/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.RequestTracing/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.RequestTracing/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Api.RequestTracing/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.RequestTracing/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.ResponseCache/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.ResponseCache/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Api.ResponseCache/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.ResponseCache/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.ResponseCache/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.ResponseCache/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Api.ResponseCache/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.ResponseCache/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.ResponseCompression/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.ResponseCompression/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Api.ResponseCompression/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.ResponseCompression/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.ResponseCompression/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.ResponseCompression/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Api.ResponseCompression/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.ResponseCompression/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Session/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Session/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Api.Session/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Session/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Session/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Session/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Api.Session/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Session/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.StartupTasks/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.StartupTasks/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Api.StartupTasks/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.StartupTasks/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.StartupTasks/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.StartupTasks/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Api.StartupTasks/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.StartupTasks/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.StaticFiles/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.StaticFiles/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Api.StaticFiles/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.StaticFiles/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.StaticFiles/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.StaticFiles/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Api.StaticFiles/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.StaticFiles/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Storage.Azure/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Storage.Azure/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Api.Storage.Azure/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Storage.Azure/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Storage.Azure/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Storage.Azure/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Api.Storage.Azure/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Storage.Azure/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Storage.Local/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Storage.Local/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Api.Storage.Local/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Storage.Local/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Storage.Local/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Storage.Local/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Api.Storage.Local/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Storage.Local/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.TimeZone/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.TimeZone/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Api.TimeZone/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.TimeZone/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.TimeZone/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.TimeZone/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Api.TimeZone/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.TimeZone/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.Versioning/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Versioning/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Api.Versioning/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Versioning/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.Versioning/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Versioning/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Api.Versioning/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Versioning/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api.VirusScan/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.VirusScan/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Api.VirusScan/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.VirusScan/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api.VirusScan/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.VirusScan/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Api.VirusScan/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.VirusScan/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Api._Blank/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api._Blank/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Api._Blank/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api._Blank/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Api._Blank/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api._Blank/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Api._Blank/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api._Blank/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Console.CustomConfigSection/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console.CustomConfigSection/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Console.CustomConfigSection/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console.CustomConfigSection/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Console.CustomConfigSection/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console.CustomConfigSection/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Console.CustomConfigSection/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console.CustomConfigSection/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Console.CustomService/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console.CustomService/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Console.CustomService/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console.CustomService/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Console.CustomService/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console.CustomService/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Console.CustomService/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console.CustomService/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Console.Data.InMemory/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console.Data.InMemory/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Console.Data.InMemory/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console.Data.InMemory/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Console.Data.InMemory/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console.Data.InMemory/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Console.Data.InMemory/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console.Data.InMemory/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Console.Data.MySql/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console.Data.MySql/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Console.Data.MySql/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console.Data.MySql/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Console.Data.MySql/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console.Data.MySql/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Console.Data.MySql/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console.Data.MySql/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Console.Data.PostgreSQL/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console.Data.PostgreSQL/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Console.Data.PostgreSQL/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console.Data.PostgreSQL/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Console.Data.PostgreSQL/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console.Data.PostgreSQL/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Console.Data.PostgreSQL/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console.Data.PostgreSQL/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Console.Data.SqLite/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console.Data.SqLite/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Console.Data.SqLite/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console.Data.SqLite/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Console.Data.SqLite/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console.Data.SqLite/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Console.Data.SqLite/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console.Data.SqLite/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Console.Data.SqlServer/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console.Data.SqlServer/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Console.Data.SqlServer/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console.Data.SqlServer/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Console.Data.SqlServer/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console.Data.SqlServer/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Console.Data.SqlServer/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console.Data.SqlServer/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Console.Eventing.RabbitMq/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console.Eventing.RabbitMq/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Console.Eventing.RabbitMq/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console.Eventing.RabbitMq/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Console.Eventing.RabbitMq/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console.Eventing.RabbitMq/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Console.Eventing.RabbitMq/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console.Eventing.RabbitMq/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Console.ExceptionHandling/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console.ExceptionHandling/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Console.ExceptionHandling/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console.ExceptionHandling/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Console.ExceptionHandling/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console.ExceptionHandling/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Console.ExceptionHandling/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console.ExceptionHandling/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Console.Localization/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console.Localization/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Console.Localization/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console.Localization/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Console.Localization/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console.Localization/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Console.Localization/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console.Localization/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Console.Logging.Log4Net/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console.Logging.Log4Net/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Console.Logging.Log4Net/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console.Logging.Log4Net/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Console.Logging.Log4Net/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console.Logging.Log4Net/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Console.Logging.Log4Net/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console.Logging.Log4Net/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Console.Logging.Microsoft/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console.Logging.Microsoft/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Console.Logging.Microsoft/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console.Logging.Microsoft/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Console.Logging.Microsoft/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console.Logging.Microsoft/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Console.Logging.Microsoft/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console.Logging.Microsoft/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Console.Logging.NLog/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console.Logging.NLog/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Console.Logging.NLog/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console.Logging.NLog/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Console.Logging.NLog/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console.Logging.NLog/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Console.Logging.NLog/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console.Logging.NLog/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Console.Logging.Serilog/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console.Logging.Serilog/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Console.Logging.Serilog/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console.Logging.Serilog/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Console.Logging.Serilog/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console.Logging.Serilog/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Console.Logging.Serilog/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console.Logging.Serilog/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Console.StartupTasks/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console.StartupTasks/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Console.StartupTasks/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console.StartupTasks/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Console.StartupTasks/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console.StartupTasks/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Console.StartupTasks/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console.StartupTasks/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Console.Storage.Azure/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console.Storage.Azure/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Console.Storage.Azure/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console.Storage.Azure/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Console.Storage.Azure/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console.Storage.Azure/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Console.Storage.Azure/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console.Storage.Azure/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Console.Storage.Local/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console.Storage.Local/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Console.Storage.Local/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console.Storage.Local/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Console.Storage.Local/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console.Storage.Local/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Console.Storage.Local/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console.Storage.Local/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Console.Workers/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console.Workers/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Console.Workers/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console.Workers/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Console.Workers/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console.Workers/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Console.Workers/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console.Workers/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Console._Blank/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console._Blank/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Console._Blank/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console._Blank/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Console._Blank/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console._Blank/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Console._Blank/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console._Blank/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. diff --git a/Web._Blank/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Web._Blank/.claude/skills/nano-add-authentication-microsoft/SKILL.md index d370556a..ae2978f1 100644 --- a/Web._Blank/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Web._Blank/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file — see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above — not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above — not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` — don't let confirming the config change alone read as the whole fix. diff --git a/Web._Blank/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Web._Blank/.github/prompts/nano-add-authentication-microsoft.prompt.md index bf456a38..ad371139 100644 --- a/Web._Blank/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Web._Blank/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -148,12 +148,8 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name ($env:APP_DISPLAY_NAME + "-secret") ` - --years 1 ` + --years 2 ` --query "password" -o tsv; - -Write-Host "TenantId: $env:AUTH_MICROSOFT_TENANT_ID"; -Write-Host "ClientId: $env:AUTH_MICROSOFT_CLIENT_ID"; -Write-Host "ClientSecret: $env:AUTH_MICROSOFT_CLIENT_SECRET"; ``` - `{service-name}` is this app's kebab-case `SERVICE_NAME` (the same value the CI workflow already @@ -233,7 +229,7 @@ Kubernetes step): --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` --display-name $env:SECRET_DISPLAY_NAME ` - --years 1 ` + --years 2 ` --query "password" -o tsv; echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; @@ -338,8 +334,8 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor additions, and the `.sln` entry. - Include the Local Development script directly in your response (not written to a file - see above), with a reminder to fill in their own redirect URI before running it, then add the three - printed values to this app's own `.docker/.env` using the `App__...` key names above - not - `appsettings.Development.json`. + values the script assigns (`$env:AUTH_MICROSOFT_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET`) to this + app's own `.docker/.env` using the `App__...` key names above - not `appsettings.Development.json`. - Restate that `offline_access` was included in `Scopes` by default, and that the client-side authorize request's own `scope` parameter must include it too for Microsoft to actually issue a `refresh_token` - don't let confirming the config change alone read as the whole fix. From 3b7311d714e500474321f05e991b646a70c81462 Mon Sep 17 00:00:00 2001 From: vivet Date: Wed, 23 Sep 2026 17:52:33 +0200 Subject: [PATCH 08/13] Updated --- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Api.ApiClients.Audit/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Api.ApiClients.Entity/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Api.ApiClients.RootLogIn/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Api.ApiClients/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Api.Auth.External.Custom/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Api.Auth.External.Microsoft/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Api.Auth.RootLogin/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Api.Authorization/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Api.ContentNegotiation/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Api.Cookies/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Api.CustomConfigSection/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Api.CustomMiddleware/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Api.CustomService/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Api.Data.Audit/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Api.Data.EntityEvents/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Api.Data.Identity.Auth.ApiKey/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- .../AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Api.Data.Identity.Auth.Jwt/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Api.Data.Identity/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Api.Data.InMemory/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Api.Data.LazyLoading/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Api.Data.MySql.Collation/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Api.Data.MySql.Mappings/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Api.Data.MySql.Spatial/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Api.Data.MySql.StoredProcedures/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Api.Data.MySql.Views/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Api.Data.MySql/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Api.Data.PostgreSQL.Spatial/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Api.Data.PostgreSQL/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Api.Data.Repository.AutoSave/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Api.Data.Repository.Includes/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Api.Data.SoftDelete/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Api.Data.SqLite/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Api.Data.SqlServer.Spatial/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Api.Data.SqlServer/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Api.Data.Triggers/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Api.Documentation.Csp/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Api.Documentation/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Api.ErrorHandling/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Api.Eventing.RabbitMq/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Api.HealthChecks/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Api.Hosting.Http/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Api.Hosting.Https/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Api.Hosting.MultipartLimits/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Api.Localization/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Api.Logging.Log4Net/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Api.Logging.Microsoft/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Api.Logging.NLog/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Api.Logging.Serilog/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Api.Metrics/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Api.MultipartJson/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- .../AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- .../AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Api.PolicyHeaders.Cors/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Api.PolicyHeaders.ForwardedHeaders/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Api.PolicyHeaders.FrameOptions/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Api.PolicyHeaders.Hsts/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Api.PolicyHeaders.ReferrerPolicy/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Api.PolicyHeaders.Robots/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Api.PolicyHeaders.XssProtection/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Api.RequestTracing/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Api.ResponseCache/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Api.ResponseCompression/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Api.Session/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Api.StartupTasks/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Api.StaticFiles/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Api.Storage.Azure/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Api.Storage.Local/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Api.TimeZone/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Api.Versioning/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Api.VirusScan/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Api._Blank/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Console.CustomConfigSection/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Console.CustomService/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Console.Data.InMemory/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Console.Data.MySql/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Console.Data.PostgreSQL/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Console.Data.SqLite/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Console.Data.SqlServer/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Console.Eventing.RabbitMq/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Console.ExceptionHandling/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Console.Localization/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Console.Logging.Log4Net/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Console.Logging.Microsoft/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Console.Logging.NLog/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Console.Logging.Serilog/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Console.StartupTasks/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Console.Storage.Azure/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Console.Storage.Local/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Console.Workers/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Console._Blank/AGENTS.md | 66 ++++++---- .../SKILL.md | 31 +++-- .../nano-add-authentication-jwt/SKILL.md | 21 +++- .../skills/nano-add-data-provider/SKILL.md | 19 ++- .../nano-add-postman-collection/SKILL.md | 115 +++++++++++++++++- ...ano-add-api-client-configuration.prompt.md | 31 +++-- .../nano-add-authentication-jwt.prompt.md | 21 +++- .../prompts/nano-add-data-provider.prompt.md | 19 ++- .../nano-add-postman-collection.prompt.md | 115 +++++++++++++++++- Web._Blank/AGENTS.md | 66 ++++++---- 828 files changed, 33764 insertions(+), 6532 deletions(-) diff --git a/Api.ApiClients.Audit/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.ApiClients.Audit/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Api.ApiClients.Audit/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.ApiClients.Audit/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.ApiClients.Audit/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.ApiClients.Audit/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Api.ApiClients.Audit/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.ApiClients.Audit/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.ApiClients.Audit/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.ApiClients.Audit/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Api.ApiClients.Audit/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.ApiClients.Audit/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.ApiClients.Audit/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.ApiClients.Audit/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Api.ApiClients.Audit/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.ApiClients.Audit/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.ApiClients.Audit/AGENTS.md b/Api.ApiClients.Audit/AGENTS.md index b0433c70..26f63772 100644 --- a/Api.ApiClients.Audit/AGENTS.md +++ b/Api.ApiClients.Audit/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Api.ApiClients.Entity/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.ApiClients.Entity/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Api.ApiClients.Entity/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.ApiClients.Entity/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.ApiClients.Entity/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.ApiClients.Entity/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Api.ApiClients.Entity/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.ApiClients.Entity/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.ApiClients.Entity/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.ApiClients.Entity/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Api.ApiClients.Entity/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.ApiClients.Entity/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.ApiClients.Entity/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.ApiClients.Entity/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Api.ApiClients.Entity/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.ApiClients.Entity/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.ApiClients.Entity/AGENTS.md b/Api.ApiClients.Entity/AGENTS.md index b0433c70..26f63772 100644 --- a/Api.ApiClients.Entity/AGENTS.md +++ b/Api.ApiClients.Entity/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Api.ApiClients.RootLogIn/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.ApiClients.RootLogIn/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Api.ApiClients.RootLogIn/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.ApiClients.RootLogIn/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.ApiClients.RootLogIn/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.ApiClients.RootLogIn/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Api.ApiClients.RootLogIn/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.ApiClients.RootLogIn/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.ApiClients.RootLogIn/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Api.ApiClients.RootLogIn/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.ApiClients.RootLogIn/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Api.ApiClients.RootLogIn/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.ApiClients.RootLogIn/AGENTS.md b/Api.ApiClients.RootLogIn/AGENTS.md index b0433c70..26f63772 100644 --- a/Api.ApiClients.RootLogIn/AGENTS.md +++ b/Api.ApiClients.RootLogIn/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Api.ApiClients/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.ApiClients/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Api.ApiClients/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.ApiClients/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.ApiClients/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.ApiClients/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Api.ApiClients/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.ApiClients/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 100644 --- a/Api.ApiClients/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.ApiClients/.claude/skills/nano-add-data-provider/SKILL.md @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 100644 --- a/Api.ApiClients/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.ApiClients/.claude/skills/nano-add-postman-collection/SKILL.md @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.ApiClients/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.ApiClients/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Api.ApiClients/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.ApiClients/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.ApiClients/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.ApiClients/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Api.ApiClients/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.ApiClients/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 100644 --- a/Api.ApiClients/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.ApiClients/.github/prompts/nano-add-data-provider.prompt.md @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 100644 --- a/Api.ApiClients/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.ApiClients/.github/prompts/nano-add-postman-collection.prompt.md @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.ApiClients/AGENTS.md b/Api.ApiClients/AGENTS.md index b0433c70..26f63772 100644 --- a/Api.ApiClients/AGENTS.md +++ b/Api.ApiClients/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Api.Auth.External.Custom/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Auth.External.Custom/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Api.Auth.External.Custom/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Auth.External.Custom/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Auth.External.Custom/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Auth.External.Custom/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Api.Auth.External.Custom/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Auth.External.Custom/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Auth.External.Custom/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Auth.External.Custom/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Api.Auth.External.Custom/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Auth.External.Custom/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Auth.External.Custom/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Auth.External.Custom/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Api.Auth.External.Custom/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Auth.External.Custom/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Auth.External.Custom/AGENTS.md b/Api.Auth.External.Custom/AGENTS.md index b0433c70..26f63772 100644 --- a/Api.Auth.External.Custom/AGENTS.md +++ b/Api.Auth.External.Custom/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Api.Auth.External.Microsoft/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Auth.External.Microsoft/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Api.Auth.External.Microsoft/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Auth.External.Microsoft/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Auth.External.Microsoft/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Auth.External.Microsoft/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Api.Auth.External.Microsoft/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Auth.External.Microsoft/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Auth.External.Microsoft/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Auth.External.Microsoft/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Api.Auth.External.Microsoft/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Auth.External.Microsoft/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Auth.External.Microsoft/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Auth.External.Microsoft/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Api.Auth.External.Microsoft/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Auth.External.Microsoft/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Auth.External.Microsoft/AGENTS.md b/Api.Auth.External.Microsoft/AGENTS.md index b0433c70..26f63772 100644 --- a/Api.Auth.External.Microsoft/AGENTS.md +++ b/Api.Auth.External.Microsoft/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Api.Auth.RootLogin/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Auth.RootLogin/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Api.Auth.RootLogin/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Auth.RootLogin/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Auth.RootLogin/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Auth.RootLogin/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Api.Auth.RootLogin/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Auth.RootLogin/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Auth.RootLogin/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Auth.RootLogin/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Api.Auth.RootLogin/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Auth.RootLogin/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Auth.RootLogin/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Auth.RootLogin/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Api.Auth.RootLogin/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Auth.RootLogin/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Auth.RootLogin/AGENTS.md b/Api.Auth.RootLogin/AGENTS.md index b0433c70..26f63772 100644 --- a/Api.Auth.RootLogin/AGENTS.md +++ b/Api.Auth.RootLogin/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Api.Authorization/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Authorization/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Api.Authorization/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Authorization/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Authorization/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Authorization/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Api.Authorization/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Authorization/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 100644 --- a/Api.Authorization/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Authorization/.claude/skills/nano-add-data-provider/SKILL.md @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 100644 --- a/Api.Authorization/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Authorization/.claude/skills/nano-add-postman-collection/SKILL.md @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Authorization/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Authorization/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Api.Authorization/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Authorization/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Authorization/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Authorization/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Api.Authorization/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Authorization/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 100644 --- a/Api.Authorization/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Authorization/.github/prompts/nano-add-data-provider.prompt.md @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 100644 --- a/Api.Authorization/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Authorization/.github/prompts/nano-add-postman-collection.prompt.md @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Authorization/AGENTS.md b/Api.Authorization/AGENTS.md index b0433c70..26f63772 100644 --- a/Api.Authorization/AGENTS.md +++ b/Api.Authorization/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Api.ContentNegotiation/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.ContentNegotiation/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Api.ContentNegotiation/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.ContentNegotiation/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.ContentNegotiation/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.ContentNegotiation/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Api.ContentNegotiation/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.ContentNegotiation/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 100644 --- a/Api.ContentNegotiation/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.ContentNegotiation/.claude/skills/nano-add-data-provider/SKILL.md @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 100644 --- a/Api.ContentNegotiation/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.ContentNegotiation/.claude/skills/nano-add-postman-collection/SKILL.md @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.ContentNegotiation/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.ContentNegotiation/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Api.ContentNegotiation/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.ContentNegotiation/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.ContentNegotiation/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.ContentNegotiation/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Api.ContentNegotiation/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.ContentNegotiation/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 100644 --- a/Api.ContentNegotiation/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.ContentNegotiation/.github/prompts/nano-add-data-provider.prompt.md @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 100644 --- a/Api.ContentNegotiation/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.ContentNegotiation/.github/prompts/nano-add-postman-collection.prompt.md @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.ContentNegotiation/AGENTS.md b/Api.ContentNegotiation/AGENTS.md index b0433c70..26f63772 100644 --- a/Api.ContentNegotiation/AGENTS.md +++ b/Api.ContentNegotiation/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Api.Cookies/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Cookies/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Api.Cookies/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Cookies/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Cookies/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Cookies/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Api.Cookies/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Cookies/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 100644 --- a/Api.Cookies/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Cookies/.claude/skills/nano-add-data-provider/SKILL.md @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 100644 --- a/Api.Cookies/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Cookies/.claude/skills/nano-add-postman-collection/SKILL.md @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Cookies/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Cookies/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Api.Cookies/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Cookies/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Cookies/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Cookies/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Api.Cookies/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Cookies/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 100644 --- a/Api.Cookies/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Cookies/.github/prompts/nano-add-data-provider.prompt.md @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 100644 --- a/Api.Cookies/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Cookies/.github/prompts/nano-add-postman-collection.prompt.md @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Cookies/AGENTS.md b/Api.Cookies/AGENTS.md index b0433c70..26f63772 100644 --- a/Api.Cookies/AGENTS.md +++ b/Api.Cookies/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Api.CustomConfigSection/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.CustomConfigSection/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Api.CustomConfigSection/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.CustomConfigSection/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.CustomConfigSection/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.CustomConfigSection/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Api.CustomConfigSection/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.CustomConfigSection/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 100644 --- a/Api.CustomConfigSection/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.CustomConfigSection/.claude/skills/nano-add-data-provider/SKILL.md @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 100644 --- a/Api.CustomConfigSection/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.CustomConfigSection/.claude/skills/nano-add-postman-collection/SKILL.md @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.CustomConfigSection/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.CustomConfigSection/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Api.CustomConfigSection/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.CustomConfigSection/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.CustomConfigSection/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.CustomConfigSection/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Api.CustomConfigSection/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.CustomConfigSection/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 100644 --- a/Api.CustomConfigSection/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.CustomConfigSection/.github/prompts/nano-add-data-provider.prompt.md @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 100644 --- a/Api.CustomConfigSection/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.CustomConfigSection/.github/prompts/nano-add-postman-collection.prompt.md @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.CustomConfigSection/AGENTS.md b/Api.CustomConfigSection/AGENTS.md index b0433c70..26f63772 100644 --- a/Api.CustomConfigSection/AGENTS.md +++ b/Api.CustomConfigSection/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Api.CustomMiddleware/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.CustomMiddleware/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Api.CustomMiddleware/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.CustomMiddleware/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.CustomMiddleware/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.CustomMiddleware/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Api.CustomMiddleware/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.CustomMiddleware/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 100644 --- a/Api.CustomMiddleware/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.CustomMiddleware/.claude/skills/nano-add-data-provider/SKILL.md @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 100644 --- a/Api.CustomMiddleware/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.CustomMiddleware/.claude/skills/nano-add-postman-collection/SKILL.md @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.CustomMiddleware/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.CustomMiddleware/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Api.CustomMiddleware/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.CustomMiddleware/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.CustomMiddleware/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.CustomMiddleware/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Api.CustomMiddleware/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.CustomMiddleware/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 100644 --- a/Api.CustomMiddleware/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.CustomMiddleware/.github/prompts/nano-add-data-provider.prompt.md @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 100644 --- a/Api.CustomMiddleware/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.CustomMiddleware/.github/prompts/nano-add-postman-collection.prompt.md @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.CustomMiddleware/AGENTS.md b/Api.CustomMiddleware/AGENTS.md index b0433c70..26f63772 100644 --- a/Api.CustomMiddleware/AGENTS.md +++ b/Api.CustomMiddleware/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Api.CustomService/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.CustomService/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Api.CustomService/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.CustomService/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.CustomService/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.CustomService/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Api.CustomService/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.CustomService/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 100644 --- a/Api.CustomService/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.CustomService/.claude/skills/nano-add-data-provider/SKILL.md @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 100644 --- a/Api.CustomService/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.CustomService/.claude/skills/nano-add-postman-collection/SKILL.md @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.CustomService/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.CustomService/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Api.CustomService/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.CustomService/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.CustomService/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.CustomService/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Api.CustomService/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.CustomService/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 100644 --- a/Api.CustomService/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.CustomService/.github/prompts/nano-add-data-provider.prompt.md @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 100644 --- a/Api.CustomService/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.CustomService/.github/prompts/nano-add-postman-collection.prompt.md @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.CustomService/AGENTS.md b/Api.CustomService/AGENTS.md index b0433c70..26f63772 100644 --- a/Api.CustomService/AGENTS.md +++ b/Api.CustomService/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Api.Data.Audit/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Data.Audit/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Api.Data.Audit/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Data.Audit/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Data.Audit/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Data.Audit/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Api.Data.Audit/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Data.Audit/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Data.Audit/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Data.Audit/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Api.Data.Audit/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Data.Audit/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Data.Audit/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Data.Audit/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Api.Data.Audit/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Data.Audit/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Data.Audit/AGENTS.md b/Api.Data.Audit/AGENTS.md index b0433c70..26f63772 100644 --- a/Api.Data.Audit/AGENTS.md +++ b/Api.Data.Audit/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Api.Data.EntityEvents/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Data.EntityEvents/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Api.Data.EntityEvents/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Data.EntityEvents/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Data.EntityEvents/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Data.EntityEvents/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Api.Data.EntityEvents/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Data.EntityEvents/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Data.EntityEvents/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Data.EntityEvents/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Api.Data.EntityEvents/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Data.EntityEvents/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Data.EntityEvents/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Data.EntityEvents/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Api.Data.EntityEvents/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Data.EntityEvents/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Data.EntityEvents/AGENTS.md b/Api.Data.EntityEvents/AGENTS.md index b0433c70..26f63772 100644 --- a/Api.Data.EntityEvents/AGENTS.md +++ b/Api.Data.EntityEvents/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Data.Identity.Auth.ApiKey/AGENTS.md b/Api.Data.Identity.Auth.ApiKey/AGENTS.md index b0433c70..26f63772 100644 --- a/Api.Data.Identity.Auth.ApiKey/AGENTS.md +++ b/Api.Data.Identity.Auth.ApiKey/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Data.Identity.Auth.External.Custom/AGENTS.md b/Api.Data.Identity.Auth.External.Custom/AGENTS.md index b0433c70..26f63772 100644 --- a/Api.Data.Identity.Auth.External.Custom/AGENTS.md +++ b/Api.Data.Identity.Auth.External.Custom/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Data.Identity.Auth.Jwt/AGENTS.md b/Api.Data.Identity.Auth.Jwt/AGENTS.md index b0433c70..26f63772 100644 --- a/Api.Data.Identity.Auth.Jwt/AGENTS.md +++ b/Api.Data.Identity.Auth.Jwt/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Api.Data.Identity/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Data.Identity/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Api.Data.Identity/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Data.Identity/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Data.Identity/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Data.Identity/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Api.Data.Identity/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Data.Identity/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Data.Identity/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Data.Identity/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Api.Data.Identity/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Data.Identity/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Data.Identity/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Data.Identity/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Api.Data.Identity/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Data.Identity/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Data.Identity/AGENTS.md b/Api.Data.Identity/AGENTS.md index b0433c70..26f63772 100644 --- a/Api.Data.Identity/AGENTS.md +++ b/Api.Data.Identity/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Api.Data.InMemory/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Data.InMemory/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Api.Data.InMemory/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Data.InMemory/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Data.InMemory/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Data.InMemory/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Api.Data.InMemory/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Data.InMemory/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Data.InMemory/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Data.InMemory/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Api.Data.InMemory/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Data.InMemory/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Data.InMemory/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Data.InMemory/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Api.Data.InMemory/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Data.InMemory/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Data.InMemory/AGENTS.md b/Api.Data.InMemory/AGENTS.md index b0433c70..26f63772 100644 --- a/Api.Data.InMemory/AGENTS.md +++ b/Api.Data.InMemory/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Api.Data.LazyLoading/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Data.LazyLoading/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Api.Data.LazyLoading/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Data.LazyLoading/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Data.LazyLoading/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Data.LazyLoading/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Api.Data.LazyLoading/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Data.LazyLoading/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Data.LazyLoading/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Data.LazyLoading/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Api.Data.LazyLoading/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Data.LazyLoading/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Data.LazyLoading/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Data.LazyLoading/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Api.Data.LazyLoading/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Data.LazyLoading/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Data.LazyLoading/AGENTS.md b/Api.Data.LazyLoading/AGENTS.md index b0433c70..26f63772 100644 --- a/Api.Data.LazyLoading/AGENTS.md +++ b/Api.Data.LazyLoading/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Api.Data.MySql.Collation/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Data.MySql.Collation/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Api.Data.MySql.Collation/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Data.MySql.Collation/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Data.MySql.Collation/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Data.MySql.Collation/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Api.Data.MySql.Collation/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Data.MySql.Collation/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Data.MySql.Collation/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Data.MySql.Collation/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Api.Data.MySql.Collation/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Data.MySql.Collation/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Data.MySql.Collation/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Data.MySql.Collation/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Api.Data.MySql.Collation/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Data.MySql.Collation/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Data.MySql.Collation/AGENTS.md b/Api.Data.MySql.Collation/AGENTS.md index b0433c70..26f63772 100644 --- a/Api.Data.MySql.Collation/AGENTS.md +++ b/Api.Data.MySql.Collation/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Api.Data.MySql.Mappings/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Data.MySql.Mappings/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Api.Data.MySql.Mappings/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Data.MySql.Mappings/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Data.MySql.Mappings/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Data.MySql.Mappings/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Api.Data.MySql.Mappings/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Data.MySql.Mappings/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Data.MySql.Mappings/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Data.MySql.Mappings/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Api.Data.MySql.Mappings/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Data.MySql.Mappings/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Data.MySql.Mappings/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Data.MySql.Mappings/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Api.Data.MySql.Mappings/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Data.MySql.Mappings/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Data.MySql.Mappings/AGENTS.md b/Api.Data.MySql.Mappings/AGENTS.md index b0433c70..26f63772 100644 --- a/Api.Data.MySql.Mappings/AGENTS.md +++ b/Api.Data.MySql.Mappings/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Api.Data.MySql.Spatial/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Data.MySql.Spatial/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Api.Data.MySql.Spatial/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Data.MySql.Spatial/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Data.MySql.Spatial/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Data.MySql.Spatial/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Api.Data.MySql.Spatial/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Data.MySql.Spatial/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Data.MySql.Spatial/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Data.MySql.Spatial/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Api.Data.MySql.Spatial/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Data.MySql.Spatial/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Data.MySql.Spatial/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Data.MySql.Spatial/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Api.Data.MySql.Spatial/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Data.MySql.Spatial/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Data.MySql.Spatial/AGENTS.md b/Api.Data.MySql.Spatial/AGENTS.md index b0433c70..26f63772 100644 --- a/Api.Data.MySql.Spatial/AGENTS.md +++ b/Api.Data.MySql.Spatial/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Data.MySql.StoredProcedures/AGENTS.md b/Api.Data.MySql.StoredProcedures/AGENTS.md index b0433c70..26f63772 100644 --- a/Api.Data.MySql.StoredProcedures/AGENTS.md +++ b/Api.Data.MySql.StoredProcedures/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Api.Data.MySql.Views/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Data.MySql.Views/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Api.Data.MySql.Views/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Data.MySql.Views/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Data.MySql.Views/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Data.MySql.Views/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Api.Data.MySql.Views/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Data.MySql.Views/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Data.MySql.Views/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Data.MySql.Views/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Api.Data.MySql.Views/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Data.MySql.Views/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Data.MySql.Views/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Data.MySql.Views/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Api.Data.MySql.Views/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Data.MySql.Views/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Data.MySql.Views/AGENTS.md b/Api.Data.MySql.Views/AGENTS.md index b0433c70..26f63772 100644 --- a/Api.Data.MySql.Views/AGENTS.md +++ b/Api.Data.MySql.Views/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Api.Data.MySql/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Data.MySql/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Api.Data.MySql/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Data.MySql/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Data.MySql/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Data.MySql/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Api.Data.MySql/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Data.MySql/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Data.MySql/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Data.MySql/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Api.Data.MySql/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Data.MySql/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Data.MySql/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Data.MySql/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Api.Data.MySql/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Data.MySql/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Data.MySql/AGENTS.md b/Api.Data.MySql/AGENTS.md index b0433c70..26f63772 100644 --- a/Api.Data.MySql/AGENTS.md +++ b/Api.Data.MySql/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Data.PostgreSQL.Spatial/AGENTS.md b/Api.Data.PostgreSQL.Spatial/AGENTS.md index b0433c70..26f63772 100644 --- a/Api.Data.PostgreSQL.Spatial/AGENTS.md +++ b/Api.Data.PostgreSQL.Spatial/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Api.Data.PostgreSQL/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Data.PostgreSQL/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Api.Data.PostgreSQL/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Data.PostgreSQL/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Data.PostgreSQL/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Data.PostgreSQL/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Api.Data.PostgreSQL/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Data.PostgreSQL/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Data.PostgreSQL/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Data.PostgreSQL/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Api.Data.PostgreSQL/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Data.PostgreSQL/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Data.PostgreSQL/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Data.PostgreSQL/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Api.Data.PostgreSQL/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Data.PostgreSQL/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Data.PostgreSQL/AGENTS.md b/Api.Data.PostgreSQL/AGENTS.md index b0433c70..26f63772 100644 --- a/Api.Data.PostgreSQL/AGENTS.md +++ b/Api.Data.PostgreSQL/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Api.Data.Repository.AutoSave/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Data.Repository.AutoSave/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Api.Data.Repository.AutoSave/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Data.Repository.AutoSave/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Data.Repository.AutoSave/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Data.Repository.AutoSave/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Api.Data.Repository.AutoSave/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Data.Repository.AutoSave/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Data.Repository.AutoSave/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Data.Repository.AutoSave/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Api.Data.Repository.AutoSave/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Data.Repository.AutoSave/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Data.Repository.AutoSave/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Data.Repository.AutoSave/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Api.Data.Repository.AutoSave/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Data.Repository.AutoSave/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Data.Repository.AutoSave/AGENTS.md b/Api.Data.Repository.AutoSave/AGENTS.md index b0433c70..26f63772 100644 --- a/Api.Data.Repository.AutoSave/AGENTS.md +++ b/Api.Data.Repository.AutoSave/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Api.Data.Repository.Includes/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Data.Repository.Includes/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Api.Data.Repository.Includes/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Data.Repository.Includes/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Data.Repository.Includes/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Data.Repository.Includes/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Api.Data.Repository.Includes/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Data.Repository.Includes/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Data.Repository.Includes/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Data.Repository.Includes/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Api.Data.Repository.Includes/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Data.Repository.Includes/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Data.Repository.Includes/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Data.Repository.Includes/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Api.Data.Repository.Includes/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Data.Repository.Includes/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Data.Repository.Includes/AGENTS.md b/Api.Data.Repository.Includes/AGENTS.md index b0433c70..26f63772 100644 --- a/Api.Data.Repository.Includes/AGENTS.md +++ b/Api.Data.Repository.Includes/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Api.Data.SoftDelete/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Data.SoftDelete/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Api.Data.SoftDelete/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Data.SoftDelete/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Data.SoftDelete/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Data.SoftDelete/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Api.Data.SoftDelete/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Data.SoftDelete/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Data.SoftDelete/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Data.SoftDelete/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Api.Data.SoftDelete/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Data.SoftDelete/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Data.SoftDelete/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Data.SoftDelete/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Api.Data.SoftDelete/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Data.SoftDelete/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Data.SoftDelete/AGENTS.md b/Api.Data.SoftDelete/AGENTS.md index b0433c70..26f63772 100644 --- a/Api.Data.SoftDelete/AGENTS.md +++ b/Api.Data.SoftDelete/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Api.Data.SqLite/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Data.SqLite/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Api.Data.SqLite/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Data.SqLite/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Data.SqLite/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Data.SqLite/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Api.Data.SqLite/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Data.SqLite/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Data.SqLite/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Data.SqLite/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Api.Data.SqLite/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Data.SqLite/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Data.SqLite/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Data.SqLite/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Api.Data.SqLite/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Data.SqLite/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Data.SqLite/AGENTS.md b/Api.Data.SqLite/AGENTS.md index b0433c70..26f63772 100644 --- a/Api.Data.SqLite/AGENTS.md +++ b/Api.Data.SqLite/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Data.SqlServer.Spatial/AGENTS.md b/Api.Data.SqlServer.Spatial/AGENTS.md index b0433c70..26f63772 100644 --- a/Api.Data.SqlServer.Spatial/AGENTS.md +++ b/Api.Data.SqlServer.Spatial/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Api.Data.SqlServer/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Data.SqlServer/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Api.Data.SqlServer/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Data.SqlServer/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Data.SqlServer/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Data.SqlServer/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Api.Data.SqlServer/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Data.SqlServer/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Data.SqlServer/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Data.SqlServer/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Api.Data.SqlServer/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Data.SqlServer/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Data.SqlServer/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Data.SqlServer/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Api.Data.SqlServer/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Data.SqlServer/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Data.SqlServer/AGENTS.md b/Api.Data.SqlServer/AGENTS.md index b0433c70..26f63772 100644 --- a/Api.Data.SqlServer/AGENTS.md +++ b/Api.Data.SqlServer/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Api.Data.Triggers/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Data.Triggers/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Api.Data.Triggers/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Data.Triggers/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Data.Triggers/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Data.Triggers/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Api.Data.Triggers/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Data.Triggers/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Data.Triggers/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Data.Triggers/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Api.Data.Triggers/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Data.Triggers/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Data.Triggers/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Data.Triggers/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Api.Data.Triggers/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Data.Triggers/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Data.Triggers/AGENTS.md b/Api.Data.Triggers/AGENTS.md index b0433c70..26f63772 100644 --- a/Api.Data.Triggers/AGENTS.md +++ b/Api.Data.Triggers/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Api.Documentation.Csp/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Documentation.Csp/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Api.Documentation.Csp/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Documentation.Csp/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Documentation.Csp/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Documentation.Csp/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Api.Documentation.Csp/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Documentation.Csp/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Documentation.Csp/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Documentation.Csp/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Api.Documentation.Csp/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Documentation.Csp/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Documentation.Csp/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Documentation.Csp/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Api.Documentation.Csp/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Documentation.Csp/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Documentation.Csp/AGENTS.md b/Api.Documentation.Csp/AGENTS.md index b0433c70..26f63772 100644 --- a/Api.Documentation.Csp/AGENTS.md +++ b/Api.Documentation.Csp/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Api.Documentation/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Documentation/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Api.Documentation/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Documentation/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Documentation/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Documentation/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Api.Documentation/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Documentation/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 100644 --- a/Api.Documentation/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Documentation/.claude/skills/nano-add-data-provider/SKILL.md @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 100644 --- a/Api.Documentation/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Documentation/.claude/skills/nano-add-postman-collection/SKILL.md @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Documentation/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Documentation/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Api.Documentation/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Documentation/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Documentation/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Documentation/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Api.Documentation/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Documentation/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 100644 --- a/Api.Documentation/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Documentation/.github/prompts/nano-add-data-provider.prompt.md @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 100644 --- a/Api.Documentation/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Documentation/.github/prompts/nano-add-postman-collection.prompt.md @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Documentation/AGENTS.md b/Api.Documentation/AGENTS.md index b0433c70..26f63772 100644 --- a/Api.Documentation/AGENTS.md +++ b/Api.Documentation/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Api.ErrorHandling/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.ErrorHandling/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Api.ErrorHandling/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.ErrorHandling/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.ErrorHandling/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.ErrorHandling/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Api.ErrorHandling/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.ErrorHandling/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 100644 --- a/Api.ErrorHandling/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.ErrorHandling/.claude/skills/nano-add-data-provider/SKILL.md @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 100644 --- a/Api.ErrorHandling/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.ErrorHandling/.claude/skills/nano-add-postman-collection/SKILL.md @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.ErrorHandling/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.ErrorHandling/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Api.ErrorHandling/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.ErrorHandling/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.ErrorHandling/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.ErrorHandling/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Api.ErrorHandling/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.ErrorHandling/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 100644 --- a/Api.ErrorHandling/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.ErrorHandling/.github/prompts/nano-add-data-provider.prompt.md @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 100644 --- a/Api.ErrorHandling/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.ErrorHandling/.github/prompts/nano-add-postman-collection.prompt.md @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.ErrorHandling/AGENTS.md b/Api.ErrorHandling/AGENTS.md index b0433c70..26f63772 100644 --- a/Api.ErrorHandling/AGENTS.md +++ b/Api.ErrorHandling/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Api.Eventing.RabbitMq/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Eventing.RabbitMq/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Api.Eventing.RabbitMq/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Eventing.RabbitMq/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Eventing.RabbitMq/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Eventing.RabbitMq/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Api.Eventing.RabbitMq/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Eventing.RabbitMq/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Eventing.RabbitMq/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Eventing.RabbitMq/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Api.Eventing.RabbitMq/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Eventing.RabbitMq/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Eventing.RabbitMq/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Eventing.RabbitMq/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Api.Eventing.RabbitMq/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Eventing.RabbitMq/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Eventing.RabbitMq/AGENTS.md b/Api.Eventing.RabbitMq/AGENTS.md index b0433c70..26f63772 100644 --- a/Api.Eventing.RabbitMq/AGENTS.md +++ b/Api.Eventing.RabbitMq/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Api.HealthChecks/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.HealthChecks/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Api.HealthChecks/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.HealthChecks/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.HealthChecks/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.HealthChecks/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Api.HealthChecks/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.HealthChecks/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 100644 --- a/Api.HealthChecks/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.HealthChecks/.claude/skills/nano-add-data-provider/SKILL.md @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 100644 --- a/Api.HealthChecks/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.HealthChecks/.claude/skills/nano-add-postman-collection/SKILL.md @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.HealthChecks/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.HealthChecks/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Api.HealthChecks/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.HealthChecks/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.HealthChecks/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.HealthChecks/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Api.HealthChecks/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.HealthChecks/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 100644 --- a/Api.HealthChecks/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.HealthChecks/.github/prompts/nano-add-data-provider.prompt.md @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 100644 --- a/Api.HealthChecks/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.HealthChecks/.github/prompts/nano-add-postman-collection.prompt.md @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.HealthChecks/AGENTS.md b/Api.HealthChecks/AGENTS.md index b0433c70..26f63772 100644 --- a/Api.HealthChecks/AGENTS.md +++ b/Api.HealthChecks/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Api.Hosting.Http/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Hosting.Http/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Api.Hosting.Http/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Hosting.Http/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Hosting.Http/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Hosting.Http/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Api.Hosting.Http/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Hosting.Http/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Hosting.Http/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Hosting.Http/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Api.Hosting.Http/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Hosting.Http/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Hosting.Http/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Hosting.Http/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Api.Hosting.Http/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Hosting.Http/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Hosting.Http/AGENTS.md b/Api.Hosting.Http/AGENTS.md index b0433c70..26f63772 100644 --- a/Api.Hosting.Http/AGENTS.md +++ b/Api.Hosting.Http/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Api.Hosting.Https/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Hosting.Https/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Api.Hosting.Https/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Hosting.Https/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Hosting.Https/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Hosting.Https/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Api.Hosting.Https/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Hosting.Https/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Hosting.Https/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Hosting.Https/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Api.Hosting.Https/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Hosting.Https/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Hosting.Https/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Hosting.Https/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Api.Hosting.Https/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Hosting.Https/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Hosting.Https/AGENTS.md b/Api.Hosting.Https/AGENTS.md index b0433c70..26f63772 100644 --- a/Api.Hosting.Https/AGENTS.md +++ b/Api.Hosting.Https/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Api.Hosting.MultipartLimits/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Hosting.MultipartLimits/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Api.Hosting.MultipartLimits/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Hosting.MultipartLimits/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Hosting.MultipartLimits/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Hosting.MultipartLimits/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Api.Hosting.MultipartLimits/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Hosting.MultipartLimits/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Hosting.MultipartLimits/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Hosting.MultipartLimits/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Api.Hosting.MultipartLimits/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Hosting.MultipartLimits/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Hosting.MultipartLimits/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Hosting.MultipartLimits/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Api.Hosting.MultipartLimits/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Hosting.MultipartLimits/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Hosting.MultipartLimits/AGENTS.md b/Api.Hosting.MultipartLimits/AGENTS.md index b0433c70..26f63772 100644 --- a/Api.Hosting.MultipartLimits/AGENTS.md +++ b/Api.Hosting.MultipartLimits/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Api.Localization/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Localization/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Api.Localization/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Localization/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Localization/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Localization/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Api.Localization/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Localization/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 100644 --- a/Api.Localization/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Localization/.claude/skills/nano-add-data-provider/SKILL.md @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 100644 --- a/Api.Localization/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Localization/.claude/skills/nano-add-postman-collection/SKILL.md @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Localization/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Localization/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Api.Localization/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Localization/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Localization/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Localization/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Api.Localization/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Localization/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 100644 --- a/Api.Localization/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Localization/.github/prompts/nano-add-data-provider.prompt.md @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 100644 --- a/Api.Localization/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Localization/.github/prompts/nano-add-postman-collection.prompt.md @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Localization/AGENTS.md b/Api.Localization/AGENTS.md index b0433c70..26f63772 100644 --- a/Api.Localization/AGENTS.md +++ b/Api.Localization/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Api.Logging.Log4Net/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Logging.Log4Net/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Api.Logging.Log4Net/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Logging.Log4Net/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Logging.Log4Net/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Logging.Log4Net/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Api.Logging.Log4Net/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Logging.Log4Net/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Logging.Log4Net/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Logging.Log4Net/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Api.Logging.Log4Net/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Logging.Log4Net/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Logging.Log4Net/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Logging.Log4Net/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Api.Logging.Log4Net/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Logging.Log4Net/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Logging.Log4Net/AGENTS.md b/Api.Logging.Log4Net/AGENTS.md index b0433c70..26f63772 100644 --- a/Api.Logging.Log4Net/AGENTS.md +++ b/Api.Logging.Log4Net/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Api.Logging.Microsoft/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Logging.Microsoft/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Api.Logging.Microsoft/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Logging.Microsoft/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Logging.Microsoft/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Logging.Microsoft/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Api.Logging.Microsoft/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Logging.Microsoft/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Logging.Microsoft/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Logging.Microsoft/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Api.Logging.Microsoft/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Logging.Microsoft/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Logging.Microsoft/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Logging.Microsoft/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Api.Logging.Microsoft/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Logging.Microsoft/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Logging.Microsoft/AGENTS.md b/Api.Logging.Microsoft/AGENTS.md index b0433c70..26f63772 100644 --- a/Api.Logging.Microsoft/AGENTS.md +++ b/Api.Logging.Microsoft/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Api.Logging.NLog/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Logging.NLog/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Api.Logging.NLog/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Logging.NLog/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Logging.NLog/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Logging.NLog/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Api.Logging.NLog/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Logging.NLog/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Logging.NLog/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Logging.NLog/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Api.Logging.NLog/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Logging.NLog/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Logging.NLog/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Logging.NLog/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Api.Logging.NLog/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Logging.NLog/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Logging.NLog/AGENTS.md b/Api.Logging.NLog/AGENTS.md index b0433c70..26f63772 100644 --- a/Api.Logging.NLog/AGENTS.md +++ b/Api.Logging.NLog/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Api.Logging.Serilog/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Logging.Serilog/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Api.Logging.Serilog/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Logging.Serilog/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Logging.Serilog/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Logging.Serilog/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Api.Logging.Serilog/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Logging.Serilog/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Logging.Serilog/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Logging.Serilog/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Api.Logging.Serilog/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Logging.Serilog/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Logging.Serilog/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Logging.Serilog/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Api.Logging.Serilog/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Logging.Serilog/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Logging.Serilog/AGENTS.md b/Api.Logging.Serilog/AGENTS.md index b0433c70..26f63772 100644 --- a/Api.Logging.Serilog/AGENTS.md +++ b/Api.Logging.Serilog/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Api.Metrics/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Metrics/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Api.Metrics/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Metrics/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Metrics/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Metrics/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Api.Metrics/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Metrics/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 100644 --- a/Api.Metrics/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Metrics/.claude/skills/nano-add-data-provider/SKILL.md @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 100644 --- a/Api.Metrics/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Metrics/.claude/skills/nano-add-postman-collection/SKILL.md @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Metrics/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Metrics/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Api.Metrics/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Metrics/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Metrics/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Metrics/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Api.Metrics/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Metrics/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 100644 --- a/Api.Metrics/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Metrics/.github/prompts/nano-add-data-provider.prompt.md @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 100644 --- a/Api.Metrics/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Metrics/.github/prompts/nano-add-postman-collection.prompt.md @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Metrics/AGENTS.md b/Api.Metrics/AGENTS.md index b0433c70..26f63772 100644 --- a/Api.Metrics/AGENTS.md +++ b/Api.Metrics/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Api.MultipartJson/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.MultipartJson/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Api.MultipartJson/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.MultipartJson/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.MultipartJson/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.MultipartJson/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Api.MultipartJson/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.MultipartJson/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 100644 --- a/Api.MultipartJson/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.MultipartJson/.claude/skills/nano-add-data-provider/SKILL.md @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 100644 --- a/Api.MultipartJson/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.MultipartJson/.claude/skills/nano-add-postman-collection/SKILL.md @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.MultipartJson/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.MultipartJson/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Api.MultipartJson/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.MultipartJson/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.MultipartJson/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.MultipartJson/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Api.MultipartJson/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.MultipartJson/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 100644 --- a/Api.MultipartJson/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.MultipartJson/.github/prompts/nano-add-data-provider.prompt.md @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 100644 --- a/Api.MultipartJson/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.MultipartJson/.github/prompts/nano-add-postman-collection.prompt.md @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.MultipartJson/AGENTS.md b/Api.MultipartJson/AGENTS.md index b0433c70..26f63772 100644 --- a/Api.MultipartJson/AGENTS.md +++ b/Api.MultipartJson/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.PolicyHeaders.ContentSecurityPolicy/AGENTS.md b/Api.PolicyHeaders.ContentSecurityPolicy/AGENTS.md index b0433c70..26f63772 100644 --- a/Api.PolicyHeaders.ContentSecurityPolicy/AGENTS.md +++ b/Api.PolicyHeaders.ContentSecurityPolicy/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.PolicyHeaders.ContentTypeOptions/AGENTS.md b/Api.PolicyHeaders.ContentTypeOptions/AGENTS.md index b0433c70..26f63772 100644 --- a/Api.PolicyHeaders.ContentTypeOptions/AGENTS.md +++ b/Api.PolicyHeaders.ContentTypeOptions/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Api.PolicyHeaders.Cors/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.PolicyHeaders.Cors/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Api.PolicyHeaders.Cors/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.PolicyHeaders.Cors/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.PolicyHeaders.Cors/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.PolicyHeaders.Cors/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Api.PolicyHeaders.Cors/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.PolicyHeaders.Cors/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.PolicyHeaders.Cors/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.PolicyHeaders.Cors/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Api.PolicyHeaders.Cors/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.PolicyHeaders.Cors/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.PolicyHeaders.Cors/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.PolicyHeaders.Cors/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Api.PolicyHeaders.Cors/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.PolicyHeaders.Cors/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.PolicyHeaders.Cors/AGENTS.md b/Api.PolicyHeaders.Cors/AGENTS.md index b0433c70..26f63772 100644 --- a/Api.PolicyHeaders.Cors/AGENTS.md +++ b/Api.PolicyHeaders.Cors/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.PolicyHeaders.ForwardedHeaders/AGENTS.md b/Api.PolicyHeaders.ForwardedHeaders/AGENTS.md index b0433c70..26f63772 100644 --- a/Api.PolicyHeaders.ForwardedHeaders/AGENTS.md +++ b/Api.PolicyHeaders.ForwardedHeaders/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.PolicyHeaders.FrameOptions/AGENTS.md b/Api.PolicyHeaders.FrameOptions/AGENTS.md index b0433c70..26f63772 100644 --- a/Api.PolicyHeaders.FrameOptions/AGENTS.md +++ b/Api.PolicyHeaders.FrameOptions/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.PolicyHeaders.Hsts/AGENTS.md b/Api.PolicyHeaders.Hsts/AGENTS.md index b0433c70..26f63772 100644 --- a/Api.PolicyHeaders.Hsts/AGENTS.md +++ b/Api.PolicyHeaders.Hsts/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.PolicyHeaders.ReferrerPolicy/AGENTS.md b/Api.PolicyHeaders.ReferrerPolicy/AGENTS.md index b0433c70..26f63772 100644 --- a/Api.PolicyHeaders.ReferrerPolicy/AGENTS.md +++ b/Api.PolicyHeaders.ReferrerPolicy/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Api.PolicyHeaders.Robots/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.PolicyHeaders.Robots/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Api.PolicyHeaders.Robots/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.PolicyHeaders.Robots/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.PolicyHeaders.Robots/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.PolicyHeaders.Robots/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Api.PolicyHeaders.Robots/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.PolicyHeaders.Robots/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.PolicyHeaders.Robots/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.PolicyHeaders.Robots/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Api.PolicyHeaders.Robots/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.PolicyHeaders.Robots/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.PolicyHeaders.Robots/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.PolicyHeaders.Robots/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Api.PolicyHeaders.Robots/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.PolicyHeaders.Robots/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.PolicyHeaders.Robots/AGENTS.md b/Api.PolicyHeaders.Robots/AGENTS.md index b0433c70..26f63772 100644 --- a/Api.PolicyHeaders.Robots/AGENTS.md +++ b/Api.PolicyHeaders.Robots/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.PolicyHeaders.XssProtection/AGENTS.md b/Api.PolicyHeaders.XssProtection/AGENTS.md index b0433c70..26f63772 100644 --- a/Api.PolicyHeaders.XssProtection/AGENTS.md +++ b/Api.PolicyHeaders.XssProtection/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Api.RequestTracing/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.RequestTracing/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Api.RequestTracing/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.RequestTracing/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.RequestTracing/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.RequestTracing/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Api.RequestTracing/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.RequestTracing/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 100644 --- a/Api.RequestTracing/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.RequestTracing/.claude/skills/nano-add-data-provider/SKILL.md @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 100644 --- a/Api.RequestTracing/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.RequestTracing/.claude/skills/nano-add-postman-collection/SKILL.md @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.RequestTracing/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.RequestTracing/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Api.RequestTracing/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.RequestTracing/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.RequestTracing/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.RequestTracing/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Api.RequestTracing/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.RequestTracing/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 100644 --- a/Api.RequestTracing/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.RequestTracing/.github/prompts/nano-add-data-provider.prompt.md @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 100644 --- a/Api.RequestTracing/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.RequestTracing/.github/prompts/nano-add-postman-collection.prompt.md @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.RequestTracing/AGENTS.md b/Api.RequestTracing/AGENTS.md index b0433c70..26f63772 100644 --- a/Api.RequestTracing/AGENTS.md +++ b/Api.RequestTracing/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Api.ResponseCache/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.ResponseCache/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Api.ResponseCache/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.ResponseCache/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.ResponseCache/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.ResponseCache/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Api.ResponseCache/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.ResponseCache/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 100644 --- a/Api.ResponseCache/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.ResponseCache/.claude/skills/nano-add-data-provider/SKILL.md @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 100644 --- a/Api.ResponseCache/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.ResponseCache/.claude/skills/nano-add-postman-collection/SKILL.md @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.ResponseCache/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.ResponseCache/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Api.ResponseCache/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.ResponseCache/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.ResponseCache/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.ResponseCache/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Api.ResponseCache/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.ResponseCache/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 100644 --- a/Api.ResponseCache/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.ResponseCache/.github/prompts/nano-add-data-provider.prompt.md @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 100644 --- a/Api.ResponseCache/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.ResponseCache/.github/prompts/nano-add-postman-collection.prompt.md @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.ResponseCache/AGENTS.md b/Api.ResponseCache/AGENTS.md index b0433c70..26f63772 100644 --- a/Api.ResponseCache/AGENTS.md +++ b/Api.ResponseCache/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Api.ResponseCompression/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.ResponseCompression/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Api.ResponseCompression/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.ResponseCompression/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.ResponseCompression/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.ResponseCompression/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Api.ResponseCompression/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.ResponseCompression/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 100644 --- a/Api.ResponseCompression/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.ResponseCompression/.claude/skills/nano-add-data-provider/SKILL.md @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 100644 --- a/Api.ResponseCompression/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.ResponseCompression/.claude/skills/nano-add-postman-collection/SKILL.md @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.ResponseCompression/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.ResponseCompression/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Api.ResponseCompression/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.ResponseCompression/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.ResponseCompression/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.ResponseCompression/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Api.ResponseCompression/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.ResponseCompression/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 100644 --- a/Api.ResponseCompression/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.ResponseCompression/.github/prompts/nano-add-data-provider.prompt.md @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 100644 --- a/Api.ResponseCompression/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.ResponseCompression/.github/prompts/nano-add-postman-collection.prompt.md @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.ResponseCompression/AGENTS.md b/Api.ResponseCompression/AGENTS.md index b0433c70..26f63772 100644 --- a/Api.ResponseCompression/AGENTS.md +++ b/Api.ResponseCompression/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Api.Session/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Session/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Api.Session/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Session/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Session/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Session/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Api.Session/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Session/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 100644 --- a/Api.Session/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Session/.claude/skills/nano-add-data-provider/SKILL.md @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 100644 --- a/Api.Session/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Session/.claude/skills/nano-add-postman-collection/SKILL.md @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Session/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Session/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Api.Session/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Session/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Session/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Session/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Api.Session/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Session/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 100644 --- a/Api.Session/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Session/.github/prompts/nano-add-data-provider.prompt.md @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 100644 --- a/Api.Session/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Session/.github/prompts/nano-add-postman-collection.prompt.md @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Session/AGENTS.md b/Api.Session/AGENTS.md index b0433c70..26f63772 100644 --- a/Api.Session/AGENTS.md +++ b/Api.Session/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Api.StartupTasks/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.StartupTasks/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Api.StartupTasks/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.StartupTasks/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.StartupTasks/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.StartupTasks/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Api.StartupTasks/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.StartupTasks/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 100644 --- a/Api.StartupTasks/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.StartupTasks/.claude/skills/nano-add-data-provider/SKILL.md @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 100644 --- a/Api.StartupTasks/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.StartupTasks/.claude/skills/nano-add-postman-collection/SKILL.md @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.StartupTasks/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.StartupTasks/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Api.StartupTasks/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.StartupTasks/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.StartupTasks/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.StartupTasks/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Api.StartupTasks/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.StartupTasks/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 100644 --- a/Api.StartupTasks/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.StartupTasks/.github/prompts/nano-add-data-provider.prompt.md @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 100644 --- a/Api.StartupTasks/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.StartupTasks/.github/prompts/nano-add-postman-collection.prompt.md @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.StartupTasks/AGENTS.md b/Api.StartupTasks/AGENTS.md index b0433c70..26f63772 100644 --- a/Api.StartupTasks/AGENTS.md +++ b/Api.StartupTasks/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Api.StaticFiles/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.StaticFiles/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Api.StaticFiles/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.StaticFiles/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.StaticFiles/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.StaticFiles/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Api.StaticFiles/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.StaticFiles/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 100644 --- a/Api.StaticFiles/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.StaticFiles/.claude/skills/nano-add-data-provider/SKILL.md @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 100644 --- a/Api.StaticFiles/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.StaticFiles/.claude/skills/nano-add-postman-collection/SKILL.md @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.StaticFiles/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.StaticFiles/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Api.StaticFiles/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.StaticFiles/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.StaticFiles/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.StaticFiles/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Api.StaticFiles/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.StaticFiles/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 100644 --- a/Api.StaticFiles/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.StaticFiles/.github/prompts/nano-add-data-provider.prompt.md @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 100644 --- a/Api.StaticFiles/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.StaticFiles/.github/prompts/nano-add-postman-collection.prompt.md @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.StaticFiles/AGENTS.md b/Api.StaticFiles/AGENTS.md index b0433c70..26f63772 100644 --- a/Api.StaticFiles/AGENTS.md +++ b/Api.StaticFiles/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Api.Storage.Azure/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Storage.Azure/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Api.Storage.Azure/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Storage.Azure/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Storage.Azure/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Storage.Azure/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Api.Storage.Azure/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Storage.Azure/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Storage.Azure/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Storage.Azure/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Api.Storage.Azure/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Storage.Azure/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Storage.Azure/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Storage.Azure/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Api.Storage.Azure/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Storage.Azure/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Storage.Azure/AGENTS.md b/Api.Storage.Azure/AGENTS.md index b0433c70..26f63772 100644 --- a/Api.Storage.Azure/AGENTS.md +++ b/Api.Storage.Azure/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Api.Storage.Local/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Storage.Local/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Api.Storage.Local/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Storage.Local/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Storage.Local/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Storage.Local/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Api.Storage.Local/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Storage.Local/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Storage.Local/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Storage.Local/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Api.Storage.Local/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Storage.Local/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Storage.Local/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Storage.Local/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Api.Storage.Local/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Storage.Local/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Storage.Local/AGENTS.md b/Api.Storage.Local/AGENTS.md index b0433c70..26f63772 100644 --- a/Api.Storage.Local/AGENTS.md +++ b/Api.Storage.Local/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Api.TimeZone/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.TimeZone/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Api.TimeZone/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.TimeZone/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.TimeZone/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.TimeZone/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Api.TimeZone/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.TimeZone/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 100644 --- a/Api.TimeZone/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.TimeZone/.claude/skills/nano-add-data-provider/SKILL.md @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 100644 --- a/Api.TimeZone/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.TimeZone/.claude/skills/nano-add-postman-collection/SKILL.md @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.TimeZone/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.TimeZone/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Api.TimeZone/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.TimeZone/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.TimeZone/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.TimeZone/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Api.TimeZone/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.TimeZone/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 100644 --- a/Api.TimeZone/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.TimeZone/.github/prompts/nano-add-data-provider.prompt.md @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 100644 --- a/Api.TimeZone/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.TimeZone/.github/prompts/nano-add-postman-collection.prompt.md @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.TimeZone/AGENTS.md b/Api.TimeZone/AGENTS.md index b0433c70..26f63772 100644 --- a/Api.TimeZone/AGENTS.md +++ b/Api.TimeZone/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Api.Versioning/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Versioning/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Api.Versioning/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Versioning/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Versioning/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Versioning/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Api.Versioning/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Versioning/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 100644 --- a/Api.Versioning/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Versioning/.claude/skills/nano-add-data-provider/SKILL.md @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 100644 --- a/Api.Versioning/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Versioning/.claude/skills/nano-add-postman-collection/SKILL.md @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Versioning/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Versioning/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Api.Versioning/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Versioning/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.Versioning/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Versioning/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Api.Versioning/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Versioning/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 100644 --- a/Api.Versioning/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Versioning/.github/prompts/nano-add-data-provider.prompt.md @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 100644 --- a/Api.Versioning/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Versioning/.github/prompts/nano-add-postman-collection.prompt.md @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.Versioning/AGENTS.md b/Api.Versioning/AGENTS.md index b0433c70..26f63772 100644 --- a/Api.Versioning/AGENTS.md +++ b/Api.Versioning/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Api.VirusScan/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.VirusScan/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Api.VirusScan/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.VirusScan/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.VirusScan/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.VirusScan/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Api.VirusScan/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.VirusScan/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 100644 --- a/Api.VirusScan/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.VirusScan/.claude/skills/nano-add-data-provider/SKILL.md @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 100644 --- a/Api.VirusScan/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.VirusScan/.claude/skills/nano-add-postman-collection/SKILL.md @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.VirusScan/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.VirusScan/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Api.VirusScan/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.VirusScan/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api.VirusScan/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.VirusScan/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Api.VirusScan/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.VirusScan/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 100644 --- a/Api.VirusScan/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.VirusScan/.github/prompts/nano-add-data-provider.prompt.md @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 100644 --- a/Api.VirusScan/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.VirusScan/.github/prompts/nano-add-postman-collection.prompt.md @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api.VirusScan/AGENTS.md b/Api.VirusScan/AGENTS.md index b0433c70..26f63772 100644 --- a/Api.VirusScan/AGENTS.md +++ b/Api.VirusScan/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Api._Blank/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api._Blank/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Api._Blank/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api._Blank/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api._Blank/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api._Blank/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Api._Blank/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api._Blank/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 100644 --- a/Api._Blank/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api._Blank/.claude/skills/nano-add-data-provider/SKILL.md @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 100644 --- a/Api._Blank/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api._Blank/.claude/skills/nano-add-postman-collection/SKILL.md @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api._Blank/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api._Blank/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Api._Blank/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api._Blank/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Api._Blank/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api._Blank/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Api._Blank/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api._Blank/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 100644 --- a/Api._Blank/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api._Blank/.github/prompts/nano-add-data-provider.prompt.md @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 100644 --- a/Api._Blank/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api._Blank/.github/prompts/nano-add-postman-collection.prompt.md @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Api._Blank/AGENTS.md b/Api._Blank/AGENTS.md index b0433c70..26f63772 100644 --- a/Api._Blank/AGENTS.md +++ b/Api._Blank/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Console.CustomConfigSection/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Console.CustomConfigSection/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Console.CustomConfigSection/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Console.CustomConfigSection/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Console.CustomConfigSection/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Console.CustomConfigSection/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Console.CustomConfigSection/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Console.CustomConfigSection/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 100644 --- a/Console.CustomConfigSection/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Console.CustomConfigSection/.claude/skills/nano-add-data-provider/SKILL.md @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 100644 --- a/Console.CustomConfigSection/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Console.CustomConfigSection/.claude/skills/nano-add-postman-collection/SKILL.md @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Console.CustomConfigSection/.github/prompts/nano-add-api-client-configuration.prompt.md b/Console.CustomConfigSection/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Console.CustomConfigSection/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Console.CustomConfigSection/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Console.CustomConfigSection/.github/prompts/nano-add-authentication-jwt.prompt.md b/Console.CustomConfigSection/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Console.CustomConfigSection/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Console.CustomConfigSection/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 100644 --- a/Console.CustomConfigSection/.github/prompts/nano-add-data-provider.prompt.md +++ b/Console.CustomConfigSection/.github/prompts/nano-add-data-provider.prompt.md @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 100644 --- a/Console.CustomConfigSection/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Console.CustomConfigSection/.github/prompts/nano-add-postman-collection.prompt.md @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Console.CustomConfigSection/AGENTS.md b/Console.CustomConfigSection/AGENTS.md index b0433c70..26f63772 100644 --- a/Console.CustomConfigSection/AGENTS.md +++ b/Console.CustomConfigSection/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Console.CustomService/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Console.CustomService/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Console.CustomService/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Console.CustomService/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Console.CustomService/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Console.CustomService/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Console.CustomService/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Console.CustomService/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 100644 --- a/Console.CustomService/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Console.CustomService/.claude/skills/nano-add-data-provider/SKILL.md @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 100644 --- a/Console.CustomService/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Console.CustomService/.claude/skills/nano-add-postman-collection/SKILL.md @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Console.CustomService/.github/prompts/nano-add-api-client-configuration.prompt.md b/Console.CustomService/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Console.CustomService/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Console.CustomService/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Console.CustomService/.github/prompts/nano-add-authentication-jwt.prompt.md b/Console.CustomService/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Console.CustomService/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Console.CustomService/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 100644 --- a/Console.CustomService/.github/prompts/nano-add-data-provider.prompt.md +++ b/Console.CustomService/.github/prompts/nano-add-data-provider.prompt.md @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 100644 --- a/Console.CustomService/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Console.CustomService/.github/prompts/nano-add-postman-collection.prompt.md @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Console.CustomService/AGENTS.md b/Console.CustomService/AGENTS.md index b0433c70..26f63772 100644 --- a/Console.CustomService/AGENTS.md +++ b/Console.CustomService/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Console.Data.InMemory/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Console.Data.InMemory/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Console.Data.InMemory/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Console.Data.InMemory/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Console.Data.InMemory/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Console.Data.InMemory/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Console.Data.InMemory/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Console.Data.InMemory/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Console.Data.InMemory/.github/prompts/nano-add-api-client-configuration.prompt.md b/Console.Data.InMemory/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Console.Data.InMemory/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Console.Data.InMemory/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Console.Data.InMemory/.github/prompts/nano-add-authentication-jwt.prompt.md b/Console.Data.InMemory/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Console.Data.InMemory/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Console.Data.InMemory/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Console.Data.InMemory/AGENTS.md b/Console.Data.InMemory/AGENTS.md index b0433c70..26f63772 100644 --- a/Console.Data.InMemory/AGENTS.md +++ b/Console.Data.InMemory/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Console.Data.MySql/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Console.Data.MySql/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Console.Data.MySql/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Console.Data.MySql/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Console.Data.MySql/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Console.Data.MySql/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Console.Data.MySql/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Console.Data.MySql/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Console.Data.MySql/.github/prompts/nano-add-api-client-configuration.prompt.md b/Console.Data.MySql/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Console.Data.MySql/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Console.Data.MySql/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Console.Data.MySql/.github/prompts/nano-add-authentication-jwt.prompt.md b/Console.Data.MySql/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Console.Data.MySql/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Console.Data.MySql/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Console.Data.MySql/AGENTS.md b/Console.Data.MySql/AGENTS.md index b0433c70..26f63772 100644 --- a/Console.Data.MySql/AGENTS.md +++ b/Console.Data.MySql/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Console.Data.PostgreSQL/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Console.Data.PostgreSQL/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Console.Data.PostgreSQL/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Console.Data.PostgreSQL/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Console.Data.PostgreSQL/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Console.Data.PostgreSQL/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Console.Data.PostgreSQL/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Console.Data.PostgreSQL/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Console.Data.PostgreSQL/.github/prompts/nano-add-api-client-configuration.prompt.md b/Console.Data.PostgreSQL/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Console.Data.PostgreSQL/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Console.Data.PostgreSQL/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Console.Data.PostgreSQL/.github/prompts/nano-add-authentication-jwt.prompt.md b/Console.Data.PostgreSQL/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Console.Data.PostgreSQL/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Console.Data.PostgreSQL/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Console.Data.PostgreSQL/AGENTS.md b/Console.Data.PostgreSQL/AGENTS.md index b0433c70..26f63772 100644 --- a/Console.Data.PostgreSQL/AGENTS.md +++ b/Console.Data.PostgreSQL/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Console.Data.SqLite/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Console.Data.SqLite/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Console.Data.SqLite/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Console.Data.SqLite/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Console.Data.SqLite/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Console.Data.SqLite/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Console.Data.SqLite/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Console.Data.SqLite/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Console.Data.SqLite/.github/prompts/nano-add-api-client-configuration.prompt.md b/Console.Data.SqLite/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Console.Data.SqLite/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Console.Data.SqLite/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Console.Data.SqLite/.github/prompts/nano-add-authentication-jwt.prompt.md b/Console.Data.SqLite/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Console.Data.SqLite/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Console.Data.SqLite/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Console.Data.SqLite/AGENTS.md b/Console.Data.SqLite/AGENTS.md index b0433c70..26f63772 100644 --- a/Console.Data.SqLite/AGENTS.md +++ b/Console.Data.SqLite/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Console.Data.SqlServer/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Console.Data.SqlServer/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Console.Data.SqlServer/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Console.Data.SqlServer/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Console.Data.SqlServer/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Console.Data.SqlServer/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Console.Data.SqlServer/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Console.Data.SqlServer/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Console.Data.SqlServer/.github/prompts/nano-add-api-client-configuration.prompt.md b/Console.Data.SqlServer/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Console.Data.SqlServer/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Console.Data.SqlServer/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Console.Data.SqlServer/.github/prompts/nano-add-authentication-jwt.prompt.md b/Console.Data.SqlServer/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Console.Data.SqlServer/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Console.Data.SqlServer/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Console.Data.SqlServer/AGENTS.md b/Console.Data.SqlServer/AGENTS.md index b0433c70..26f63772 100644 --- a/Console.Data.SqlServer/AGENTS.md +++ b/Console.Data.SqlServer/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Console.Eventing.RabbitMq/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Console.Eventing.RabbitMq/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Console.Eventing.RabbitMq/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Console.Eventing.RabbitMq/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Console.Eventing.RabbitMq/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Console.Eventing.RabbitMq/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Console.Eventing.RabbitMq/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Console.Eventing.RabbitMq/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Console.Eventing.RabbitMq/.github/prompts/nano-add-api-client-configuration.prompt.md b/Console.Eventing.RabbitMq/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Console.Eventing.RabbitMq/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Console.Eventing.RabbitMq/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Console.Eventing.RabbitMq/.github/prompts/nano-add-authentication-jwt.prompt.md b/Console.Eventing.RabbitMq/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Console.Eventing.RabbitMq/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Console.Eventing.RabbitMq/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Console.Eventing.RabbitMq/AGENTS.md b/Console.Eventing.RabbitMq/AGENTS.md index b0433c70..26f63772 100644 --- a/Console.Eventing.RabbitMq/AGENTS.md +++ b/Console.Eventing.RabbitMq/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Console.ExceptionHandling/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Console.ExceptionHandling/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Console.ExceptionHandling/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Console.ExceptionHandling/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Console.ExceptionHandling/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Console.ExceptionHandling/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Console.ExceptionHandling/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Console.ExceptionHandling/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 100644 --- a/Console.ExceptionHandling/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Console.ExceptionHandling/.claude/skills/nano-add-data-provider/SKILL.md @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 100644 --- a/Console.ExceptionHandling/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Console.ExceptionHandling/.claude/skills/nano-add-postman-collection/SKILL.md @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Console.ExceptionHandling/.github/prompts/nano-add-api-client-configuration.prompt.md b/Console.ExceptionHandling/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Console.ExceptionHandling/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Console.ExceptionHandling/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Console.ExceptionHandling/.github/prompts/nano-add-authentication-jwt.prompt.md b/Console.ExceptionHandling/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Console.ExceptionHandling/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Console.ExceptionHandling/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 100644 --- a/Console.ExceptionHandling/.github/prompts/nano-add-data-provider.prompt.md +++ b/Console.ExceptionHandling/.github/prompts/nano-add-data-provider.prompt.md @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 100644 --- a/Console.ExceptionHandling/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Console.ExceptionHandling/.github/prompts/nano-add-postman-collection.prompt.md @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Console.ExceptionHandling/AGENTS.md b/Console.ExceptionHandling/AGENTS.md index b0433c70..26f63772 100644 --- a/Console.ExceptionHandling/AGENTS.md +++ b/Console.ExceptionHandling/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Console.Localization/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Console.Localization/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Console.Localization/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Console.Localization/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Console.Localization/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Console.Localization/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Console.Localization/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Console.Localization/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 100644 --- a/Console.Localization/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Console.Localization/.claude/skills/nano-add-data-provider/SKILL.md @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 100644 --- a/Console.Localization/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Console.Localization/.claude/skills/nano-add-postman-collection/SKILL.md @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Console.Localization/.github/prompts/nano-add-api-client-configuration.prompt.md b/Console.Localization/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Console.Localization/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Console.Localization/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Console.Localization/.github/prompts/nano-add-authentication-jwt.prompt.md b/Console.Localization/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Console.Localization/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Console.Localization/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 100644 --- a/Console.Localization/.github/prompts/nano-add-data-provider.prompt.md +++ b/Console.Localization/.github/prompts/nano-add-data-provider.prompt.md @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 100644 --- a/Console.Localization/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Console.Localization/.github/prompts/nano-add-postman-collection.prompt.md @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Console.Localization/AGENTS.md b/Console.Localization/AGENTS.md index b0433c70..26f63772 100644 --- a/Console.Localization/AGENTS.md +++ b/Console.Localization/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Console.Logging.Log4Net/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Console.Logging.Log4Net/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Console.Logging.Log4Net/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Console.Logging.Log4Net/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Console.Logging.Log4Net/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Console.Logging.Log4Net/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Console.Logging.Log4Net/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Console.Logging.Log4Net/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Console.Logging.Log4Net/.github/prompts/nano-add-api-client-configuration.prompt.md b/Console.Logging.Log4Net/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Console.Logging.Log4Net/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Console.Logging.Log4Net/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Console.Logging.Log4Net/.github/prompts/nano-add-authentication-jwt.prompt.md b/Console.Logging.Log4Net/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Console.Logging.Log4Net/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Console.Logging.Log4Net/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Console.Logging.Log4Net/AGENTS.md b/Console.Logging.Log4Net/AGENTS.md index b0433c70..26f63772 100644 --- a/Console.Logging.Log4Net/AGENTS.md +++ b/Console.Logging.Log4Net/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Console.Logging.Microsoft/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Console.Logging.Microsoft/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Console.Logging.Microsoft/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Console.Logging.Microsoft/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Console.Logging.Microsoft/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Console.Logging.Microsoft/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Console.Logging.Microsoft/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Console.Logging.Microsoft/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Console.Logging.Microsoft/.github/prompts/nano-add-api-client-configuration.prompt.md b/Console.Logging.Microsoft/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Console.Logging.Microsoft/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Console.Logging.Microsoft/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Console.Logging.Microsoft/.github/prompts/nano-add-authentication-jwt.prompt.md b/Console.Logging.Microsoft/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Console.Logging.Microsoft/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Console.Logging.Microsoft/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Console.Logging.Microsoft/AGENTS.md b/Console.Logging.Microsoft/AGENTS.md index b0433c70..26f63772 100644 --- a/Console.Logging.Microsoft/AGENTS.md +++ b/Console.Logging.Microsoft/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Console.Logging.NLog/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Console.Logging.NLog/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Console.Logging.NLog/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Console.Logging.NLog/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Console.Logging.NLog/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Console.Logging.NLog/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Console.Logging.NLog/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Console.Logging.NLog/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Console.Logging.NLog/.github/prompts/nano-add-api-client-configuration.prompt.md b/Console.Logging.NLog/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Console.Logging.NLog/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Console.Logging.NLog/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Console.Logging.NLog/.github/prompts/nano-add-authentication-jwt.prompt.md b/Console.Logging.NLog/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Console.Logging.NLog/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Console.Logging.NLog/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Console.Logging.NLog/AGENTS.md b/Console.Logging.NLog/AGENTS.md index b0433c70..26f63772 100644 --- a/Console.Logging.NLog/AGENTS.md +++ b/Console.Logging.NLog/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Console.Logging.Serilog/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Console.Logging.Serilog/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Console.Logging.Serilog/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Console.Logging.Serilog/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Console.Logging.Serilog/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Console.Logging.Serilog/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Console.Logging.Serilog/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Console.Logging.Serilog/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Console.Logging.Serilog/.github/prompts/nano-add-api-client-configuration.prompt.md b/Console.Logging.Serilog/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Console.Logging.Serilog/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Console.Logging.Serilog/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Console.Logging.Serilog/.github/prompts/nano-add-authentication-jwt.prompt.md b/Console.Logging.Serilog/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Console.Logging.Serilog/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Console.Logging.Serilog/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Console.Logging.Serilog/AGENTS.md b/Console.Logging.Serilog/AGENTS.md index b0433c70..26f63772 100644 --- a/Console.Logging.Serilog/AGENTS.md +++ b/Console.Logging.Serilog/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Console.StartupTasks/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Console.StartupTasks/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Console.StartupTasks/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Console.StartupTasks/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Console.StartupTasks/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Console.StartupTasks/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Console.StartupTasks/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Console.StartupTasks/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 100644 --- a/Console.StartupTasks/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Console.StartupTasks/.claude/skills/nano-add-data-provider/SKILL.md @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 100644 --- a/Console.StartupTasks/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Console.StartupTasks/.claude/skills/nano-add-postman-collection/SKILL.md @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Console.StartupTasks/.github/prompts/nano-add-api-client-configuration.prompt.md b/Console.StartupTasks/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Console.StartupTasks/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Console.StartupTasks/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Console.StartupTasks/.github/prompts/nano-add-authentication-jwt.prompt.md b/Console.StartupTasks/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Console.StartupTasks/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Console.StartupTasks/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 100644 --- a/Console.StartupTasks/.github/prompts/nano-add-data-provider.prompt.md +++ b/Console.StartupTasks/.github/prompts/nano-add-data-provider.prompt.md @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 100644 --- a/Console.StartupTasks/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Console.StartupTasks/.github/prompts/nano-add-postman-collection.prompt.md @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Console.StartupTasks/AGENTS.md b/Console.StartupTasks/AGENTS.md index b0433c70..26f63772 100644 --- a/Console.StartupTasks/AGENTS.md +++ b/Console.StartupTasks/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Console.Storage.Azure/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Console.Storage.Azure/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Console.Storage.Azure/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Console.Storage.Azure/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Console.Storage.Azure/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Console.Storage.Azure/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Console.Storage.Azure/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Console.Storage.Azure/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Console.Storage.Azure/.github/prompts/nano-add-api-client-configuration.prompt.md b/Console.Storage.Azure/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Console.Storage.Azure/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Console.Storage.Azure/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Console.Storage.Azure/.github/prompts/nano-add-authentication-jwt.prompt.md b/Console.Storage.Azure/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Console.Storage.Azure/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Console.Storage.Azure/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Console.Storage.Azure/AGENTS.md b/Console.Storage.Azure/AGENTS.md index b0433c70..26f63772 100644 --- a/Console.Storage.Azure/AGENTS.md +++ b/Console.Storage.Azure/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Console.Storage.Local/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Console.Storage.Local/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Console.Storage.Local/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Console.Storage.Local/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Console.Storage.Local/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Console.Storage.Local/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Console.Storage.Local/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Console.Storage.Local/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Console.Storage.Local/.github/prompts/nano-add-api-client-configuration.prompt.md b/Console.Storage.Local/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Console.Storage.Local/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Console.Storage.Local/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Console.Storage.Local/.github/prompts/nano-add-authentication-jwt.prompt.md b/Console.Storage.Local/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Console.Storage.Local/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Console.Storage.Local/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 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 @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 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 @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Console.Storage.Local/AGENTS.md b/Console.Storage.Local/AGENTS.md index b0433c70..26f63772 100644 --- a/Console.Storage.Local/AGENTS.md +++ b/Console.Storage.Local/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Console.Workers/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Console.Workers/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Console.Workers/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Console.Workers/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Console.Workers/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Console.Workers/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Console.Workers/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Console.Workers/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 100644 --- a/Console.Workers/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Console.Workers/.claude/skills/nano-add-data-provider/SKILL.md @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 100644 --- a/Console.Workers/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Console.Workers/.claude/skills/nano-add-postman-collection/SKILL.md @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Console.Workers/.github/prompts/nano-add-api-client-configuration.prompt.md b/Console.Workers/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Console.Workers/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Console.Workers/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Console.Workers/.github/prompts/nano-add-authentication-jwt.prompt.md b/Console.Workers/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Console.Workers/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Console.Workers/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 100644 --- a/Console.Workers/.github/prompts/nano-add-data-provider.prompt.md +++ b/Console.Workers/.github/prompts/nano-add-data-provider.prompt.md @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 100644 --- a/Console.Workers/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Console.Workers/.github/prompts/nano-add-postman-collection.prompt.md @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Console.Workers/AGENTS.md b/Console.Workers/AGENTS.md index b0433c70..26f63772 100644 --- a/Console.Workers/AGENTS.md +++ b/Console.Workers/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Console._Blank/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Console._Blank/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Console._Blank/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Console._Blank/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Console._Blank/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Console._Blank/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Console._Blank/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Console._Blank/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 100644 --- a/Console._Blank/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Console._Blank/.claude/skills/nano-add-data-provider/SKILL.md @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 100644 --- a/Console._Blank/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Console._Blank/.claude/skills/nano-add-postman-collection/SKILL.md @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Console._Blank/.github/prompts/nano-add-api-client-configuration.prompt.md b/Console._Blank/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Console._Blank/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Console._Blank/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Console._Blank/.github/prompts/nano-add-authentication-jwt.prompt.md b/Console._Blank/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Console._Blank/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Console._Blank/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 100644 --- a/Console._Blank/.github/prompts/nano-add-data-provider.prompt.md +++ b/Console._Blank/.github/prompts/nano-add-data-provider.prompt.md @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 100644 --- a/Console._Blank/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Console._Blank/.github/prompts/nano-add-postman-collection.prompt.md @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Console._Blank/AGENTS.md b/Console._Blank/AGENTS.md index b0433c70..26f63772 100644 --- a/Console._Blank/AGENTS.md +++ b/Console._Blank/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's diff --git a/Web._Blank/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Web._Blank/.claude/skills/nano-add-api-client-configuration/SKILL.md index 5d576fb8..61fdfd23 100644 --- a/Web._Blank/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Web._Blank/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template — **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template — it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** — its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` — never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build — see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes — they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone — + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Web._Blank/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Web._Blank/.claude/skills/nano-add-authentication-jwt/SKILL.md index 417ed0cb..ad8fcca6 100644 --- a/Web._Blank/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Web._Blank/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds — an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. — see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT — including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) — a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 6bb5bd37..281822fb 100644 --- a/Web._Blank/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Web._Blank/.claude/skills/nano-add-data-provider/SKILL.md @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name — `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives — a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database — but unlike `InMemory` it does need K8s storage so the file survives it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere — this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** — add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 6e1d1061..bfe4b2d3 100644 --- a/Web._Blank/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Web._Blank/.claude/skills/nano-add-postman-collection/SKILL.md @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured — see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 — + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Web._Blank/.github/prompts/nano-add-api-client-configuration.prompt.md b/Web._Blank/.github/prompts/nano-add-api-client-configuration.prompt.md index 1a097451..f21ad234 100644 --- a/Web._Blank/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Web._Blank/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -158,16 +158,27 @@ Console app's own compose service has no `ports` of its own to worry about colli Wiring the reference now means a secret added to the target later needs nothing done on this app's side to pick it up — don't skip this because the target doesn't currently have any secrets in its `.env`. -4. **Wire the publish step into `.docker/docker-compose.dcproj`**: - - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's - template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` - incremental-build wiring. - - If it already exists (this app already consumes at least one other Api Client), add the new - target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` - `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` - globs, excluding `bin`/`obj`) — don't create a second script or a second target. -5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under - `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**, per AGENTS.md's template - **one MSBuild + target per nested dependency, never one shared target for all of them**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's template - it + takes `-Project`/`-StampName` parameters and publishes exactly one project per invocation, no dependency + list of its own). + - Whether this is the first dependency or the app already consumes others, add a **new, separate** + `{Target}Sources` `ItemGroup` entry (the target's main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`, **plus `**\*.json` on the target's main project only** - its `.Models` + project doesn't ship any) and a **new, separate** `Publish{Target}` MSBuild target with its own + `Inputs="@({Target}Sources)"` and its own `Outputs=".../bin/publish-{target}.stamp"` - never add to an + existing dependency's `ItemGroup`/`Inputs`/stamp, and never fold two dependencies into one target. This + per-dependency split is what lets MSBuild republish (and let Docker rebuild) only the one dependency that + actually changed, instead of every nested dependency on every build - see AGENTS.md's own ⚠ on this. + Skipping the `.json` glob has the same failure mode as before: a config-only `appsettings.*.json` edit + silently never triggers a republish, leaving stale config baked into the image indefinitely. +5. No `.gitignore` entry is needed for any stamp file the script writes - they all live under `.docker/bin/`, + already covered by the solution's standard `**/bin` ignore rule. +6. **Tell the user Rebuild → F5 is the required workflow after changing a nested dependency**, not F5 alone - + see AGENTS.md's own ⚠ on this: a plain F5 while nothing is running doesn't reliably re-invoke these targets, + even though an explicit Rebuild does. This isn't something a project file can fix; it's Visual Studio's own + build-vs-launch trigger behavior for `docker-compose` projects. ## After making the change diff --git a/Web._Blank/.github/prompts/nano-add-authentication-jwt.prompt.md b/Web._Blank/.github/prompts/nano-add-authentication-jwt.prompt.md index 7ae3707e..32387d4a 100644 --- a/Web._Blank/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Web._Blank/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -159,9 +159,18 @@ to whoever can reach `AuthController` at all. Write a custom controller instead (derive it from this app's own base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see - `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements - `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. + from caller input. + ⚠ **A custom controller built this way must still explicitly set `TransientRoles` on the + `LogInExternal`/`LogIn` request it builds - an omitted `TransientRoles` silently defaults to an + empty array**, not an error. The resulting JWT authenticates fine (login itself returns `200 OK`) + but carries no role claims at all, so it satisfies none of Nano's built-in role-based policies + (`NanoRead`/`NanoAdd`/etc. - see AGENTS.md's Authorization section) on *any* downstream call that + forwards this JWT - including this app's own generic entity controllers, and any internal service + reached through an Api Client. This surfaces as a `403 Forbidden` on a later, unrelated-looking + request, not at login, which makes it easy to misdiagnose as a routing/CORS/collection problem + instead of a missing field on the login endpoint. If the app grants a fixed set of custom claims + unconditionally (e.g. `IsAdmin: true` for anyone who authenticates), set the matching + `TransientRoles` (e.g. `administrator`) on the same request, not just the custom claims. - Nano additionally auto-maps built-in transient external-login endpoints (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see @@ -169,9 +178,9 @@ to whoever can reach `AuthController` at all. type scan, not by whether this specific controller is the one deriving it. This is an extra exposure specific to transient auth: it means a custom controller alone isn't enough to shield a transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived - class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't - derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but - isn't a caller-trust risk the way login is - see above. + class anywhere in the app) - a custom controller only actually avoids this exposure because it + doesn't derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same + gate, but isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` 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 fecad9a6..b38da06b 100644 --- a/Web._Blank/.github/prompts/nano-add-data-provider.prompt.md +++ b/Web._Blank/.github/prompts/nano-add-data-provider.prompt.md @@ -36,6 +36,14 @@ without breaking what's already there. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` 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 + `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` + as a starting point without re-deriving this value for the new app. ## Program.cs @@ -94,7 +102,8 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. +service name wouldn't resolve outside the compose network at all. Use step 6's derived database +name for the connection string's `Database=`/equivalent value. ⚠ Add only the chosen provider's connection string, active. Don't add the other providers' connection strings as commented-out alternatives - a project commits to exactly one provider, and @@ -145,7 +154,7 @@ database: environment: POSTGRES_USER: sa POSTGRES_PASSWORD: myPassword_123 - POSTGRES_DB: nanoDb + POSTGRES_DB: # SqlServer database: @@ -170,8 +179,8 @@ database - but unlike `InMemory` it does need K8s storage so the file survives p it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and - `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the - same in every environment. There's no external CI migration path for SqLite the way there is + `"ConnectionString": "Data Source=/mnt/data/.sqlite"` go + directly in the base file, the same in every environment. There's no external CI migration path for SqLite the way there is for the network providers, so the app must self-migrate at startup everywhere - this is the one case where `Migrate` outside `Development` is correct, not an AGENTS.md violation. - **`.kubernetes/data-storageclass.yaml`** (new file): @@ -267,7 +276,7 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml SQL_AUTH_TYPE: Azure - SQL_NAME: + SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` 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 86bc975c..4d623510 100644 --- a/Web._Blank/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Web._Blank/.github/prompts/nano-add-postman-collection.prompt.md @@ -57,9 +57,10 @@ it impossible to swap the Postman auth token between apps without stepping on an `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 - the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login - Root/Login Refresh/Logout set, plus an External Login folder if `nano-add-authentication-jwt`'s - `ExternalLogins` (Microsoft/Google/Facebook) is configured - see that skill and - `nano-add-authentication-microsoft` for the PKCE/authorize-URL flow if Microsoft is present. + 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 + `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 per-app-configured and commonly differ. @@ -160,6 +161,110 @@ 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. +## 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 +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 + 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 + 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 + 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 + 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 + 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 + 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 - + 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` + - `code_challenge` (its SHA-256, base64url, no padding): `d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY` + + 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 + 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 + `--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, + 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 + 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. + +## .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 +(Currencies, Product Categories) may have no Create endpoint in that app at all. When this +happens, don't just note it in a description and leave the tester to go run a different +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 + (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 + 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 + `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) + 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 + 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 + 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 + 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 + 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 + that isn't set yet. + ## File output - One file per application: `Postman_.json` (e.g. `Postman_Svc.Accounts.json`, @@ -179,7 +284,9 @@ Add a `## Testing with Postman` section to the application's `README.md`, pointi `Postman_.json` alongside it in the same folder, and listing the ordered request flow (matching the collection's own folder order) with a one-line note per step for anything non-obvious: which variable it sets, whether it's `AllowAnonymous`, whether it's Create-only/ -read-only and why, and any business-rule guard worth knowing about before calling it. +read-only and why, and any business-rule guard worth knowing about before calling it. If a +`.Prerequisites` folder exists, it's step one, with a short explanation of what it bootstraps, that +it only applies to `Development`, and which variables it hands off to the rest of the flow. ## After making the change diff --git a/Web._Blank/AGENTS.md b/Web._Blank/AGENTS.md index b0433c70..26f63772 100644 --- a/Web._Blank/AGENTS.md +++ b/Web._Blank/AGENTS.md @@ -450,36 +450,60 @@ secret added to the target later needs nothing done on the consuming app's side fine specifically because the file is scoped to one dependency, and any consumer already fully depends on that whole service anyway. -**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: - -1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its - own `bin/publish` folder. -2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` - set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing - at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build - comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single - `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the - solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. +**Publishing happens incrementally, one MSBuild target per nested dependency — never one shared target for all of +them.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es **one** project — the one named + by its `-Project`/`-StampName` parameters — to that project's own `bin/publish` folder. It takes no list of + dependencies itself; which project gets published is entirely up to whichever target invokes it. +2. **One `Publish{Target}` MSBuild target per nested dependency** (`PublishSvcAccounts`, `PublishSvcAssets`, …, not + a single shared `PublishDependentServices` covering all of them), each hooked to + `BeforeTargets="DockerPrepareForBuild"`, with its own `Inputs` — a glob of just *that* dependency's (and its + `.Models` project's) `.cs`/`.csproj` files, **plus that dependency's own `.json` files** (`appsettings*.json`, + not its `.Models` project's — those don't ship any) — and its own `Outputs`, a per-dependency stamp file + (`bin\publish-{target}.stamp`). **This per-dependency split is load-bearing, not a style choice**: a single + shared target across every dependency means editing *one* service's source republishes (and, transitively, + rebuilds the Docker image for) every other nested service too, on every build — with 5+ dependencies nested, + that's a real, avoidable slowdown on every single F5. Splitting the target per dependency means MSBuild's own + incremental comparison only reruns `dotnet publish` for the dependency whose own files actually changed; the + rest are untouched, and Docker's own layer cache then skips rebuilding their images too since their + `bin/publish` content didn't change. + ⚠ The `.json` glob is load-bearing, not decorative — a config-only edit to a dependency's `appsettings.*.json` + (a connection string, a database name) is exactly as real a change as editing its `.cs` files, but without it + in that dependency's own `Inputs`, MSBuild sees no changed input, skips the publish pass for it, and the stale + `bin\publish` output (with the old config baked in) keeps getting `COPY`'d into the image indefinitely — + surviving `docker compose down`, killing containers, even closing and reopening the IDE, since none of that + touches the stamp file this target actually keys off. If this ever needs a manual unstick regardless, delete + that dependency's own `.docker\bin\publish-{target}.stamp` (forces a republish of just that one dependency on + the next build) or run `publish-dependencies.ps1 -Project -StampName ` directly. ```xml - + - - + Inputs="@(SvcMyTargetSources)" + Outputs="$(MSBuildProjectDirectory)\bin\publish-svc-mytarget.stamp"> + ``` -This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before -launching it, which runs this target, which republishes only the dependencies whose source actually changed, -before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — -it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates -that `bin` folder if it doesn't exist yet). +A second nested dependency means one more `{Target}Sources` `ItemGroup` entry and one more `Publish{Target}` block, +following the same shape — never adding to an existing dependency's `Inputs` glob or reusing its stamp file. No +`.gitignore` entry is needed for any of the stamp files — they all live under `.docker\bin\`, already covered by +the solution's standard `**/bin` ignore rule (the script creates that `bin` folder if it doesn't exist yet). + +⚠ **In practice, a plain F5 while nothing is currently running does not reliably trigger these targets at all** — +Visual Studio's own "is a build actually needed" check for the `docker-compose` project doesn't always decide a +rebuild is warranted just because a nested dependency's source changed, even though MSBuild's own `Inputs`/ +`Outputs` comparison (if actually invoked) would correctly say it's out of date. An explicit **Rebuild** on the +solution/`docker-compose` project *does* reliably invoke it. The practical workflow after changing a nested +dependency is therefore **Rebuild → F5**, not F5 alone — Rebuild also removes the running containers, but since +the per-dependency stamps mean only the changed dependency actually republishes and rebuilds its image, the +following F5 recreating every container is fast for the unchanged ones (cached images) regardless. ⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's From 368411d735c36a166ac714861fa97b1c2d42cf34 Mon Sep 17 00:00:00 2001 From: vivet Date: Sun, 27 Sep 2026 15:27:38 +0200 Subject: [PATCH 09/13] Updated --- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Api.ApiClients.Audit/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Api.ApiClients.Entity/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Api.ApiClients.RootLogIn/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Api.ApiClients/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Api.Auth.External.Custom/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ .../.github/workflows/build-and-deploy.yml | 6 +- Api.Auth.External.Microsoft/AGENTS.md | 35 +++++++----- Api.Auth.External.Microsoft/README.md | 18 +++--- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Api.Auth.RootLogin/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Api.Authorization/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Api.ContentNegotiation/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Api.Cookies/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Api.CustomConfigSection/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Api.CustomMiddleware/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Api.CustomService/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Api.Data.Audit/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Api.Data.EntityEvents/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Api.Data.Identity.Auth.ApiKey/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ .../AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Api.Data.Identity.Auth.Jwt/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Api.Data.Identity/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Api.Data.InMemory/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Api.Data.LazyLoading/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Api.Data.MySql.Collation/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Api.Data.MySql.Mappings/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Api.Data.MySql.Spatial/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Api.Data.MySql.StoredProcedures/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Api.Data.MySql.Views/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Api.Data.MySql/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Api.Data.PostgreSQL.Spatial/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Api.Data.PostgreSQL/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Api.Data.Repository.AutoSave/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Api.Data.Repository.Includes/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Api.Data.SoftDelete/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Api.Data.SqLite/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Api.Data.SqlServer.Spatial/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Api.Data.SqlServer/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Api.Data.Triggers/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Api.Documentation.Csp/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Api.Documentation/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Api.ErrorHandling/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Api.Eventing.RabbitMq/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Api.HealthChecks/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Api.Hosting.Http/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Api.Hosting.Https/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Api.Hosting.MultipartLimits/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Api.Localization/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Api.Logging.Log4Net/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Api.Logging.Microsoft/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Api.Logging.NLog/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Api.Logging.Serilog/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Api.Metrics/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Api.MultipartJson/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ .../AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ .../AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Api.PolicyHeaders.Cors/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Api.PolicyHeaders.ForwardedHeaders/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Api.PolicyHeaders.FrameOptions/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Api.PolicyHeaders.Hsts/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Api.PolicyHeaders.ReferrerPolicy/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Api.PolicyHeaders.Robots/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Api.PolicyHeaders.XssProtection/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Api.RequestTracing/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Api.ResponseCache/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Api.ResponseCompression/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Api.Session/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Api.StartupTasks/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Api.StaticFiles/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Api.Storage.Azure/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Api.Storage.Local/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Api.TimeZone/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Api.Versioning/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Api.VirusScan/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Api._Blank/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Console.CustomConfigSection/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Console.CustomService/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Console.Data.InMemory/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Console.Data.MySql/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Console.Data.PostgreSQL/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Console.Data.SqLite/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Console.Data.SqlServer/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Console.Eventing.RabbitMq/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Console.ExceptionHandling/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Console.Localization/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Console.Logging.Log4Net/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Console.Logging.Microsoft/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Console.Logging.NLog/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Console.Logging.Serilog/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Console.StartupTasks/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Console.Storage.Azure/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Console.Storage.Local/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Console.Workers/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Console._Blank/AGENTS.md | 35 +++++++----- .../SKILL.md | 53 ++++++++++------- .../skills/nano-add-custom-endpoint/SKILL.md | 54 +++++++++++++++++- .../.claude/skills/nano-add-entity/SKILL.md | 57 ++++++++++++++++--- .../nano-add-postman-collection/SKILL.md | 2 + .../nano-remove-custom-endpoint/SKILL.md | 6 ++ .../skills/nano-remove-entity/SKILL.md | 6 ++ ...ano-add-api-client-configuration.prompt.md | 53 ++++++++++------- .../nano-add-custom-endpoint.prompt.md | 53 ++++++++++++++++- .../.github/prompts/nano-add-entity.prompt.md | 57 ++++++++++++++++--- .../nano-add-postman-collection.prompt.md | 2 + .../nano-remove-custom-endpoint.prompt.md | 6 ++ .../prompts/nano-remove-entity.prompt.md | 6 ++ Web._Blank/AGENTS.md | 35 +++++++----- 1198 files changed, 29363 insertions(+), 6541 deletions(-) diff --git a/Api.ApiClients.Audit/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.ApiClients.Audit/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Api.ApiClients.Audit/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.ApiClients.Audit/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 994e8e3f..d9f45740 100644 --- a/Api.ApiClients.Audit/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.ApiClients.Audit/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Api.ApiClients.Audit/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Api.ApiClients.Audit/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Api.ApiClients.Audit/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Api.ApiClients.Audit/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.ApiClients.Audit/.claude/skills/nano-remove-entity/SKILL.md b/Api.ApiClients.Audit/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Api.ApiClients.Audit/.claude/skills/nano-remove-entity/SKILL.md +++ b/Api.ApiClients.Audit/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.ApiClients.Audit/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.ApiClients.Audit/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Api.ApiClients.Audit/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.ApiClients.Audit/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 75ed9535..f8345ca6 100644 --- a/Api.ApiClients.Audit/.github/prompts/nano-add-entity.prompt.md +++ b/Api.ApiClients.Audit/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Api.ApiClients.Audit/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api.ApiClients.Audit/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Api.ApiClients.Audit/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Api.ApiClients.Audit/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.ApiClients.Audit/.github/prompts/nano-remove-entity.prompt.md b/Api.ApiClients.Audit/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Api.ApiClients.Audit/.github/prompts/nano-remove-entity.prompt.md +++ b/Api.ApiClients.Audit/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.ApiClients.Audit/AGENTS.md b/Api.ApiClients.Audit/AGENTS.md index 26f63772..3805edf2 100644 --- a/Api.ApiClients.Audit/AGENTS.md +++ b/Api.ApiClients.Audit/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Api.ApiClients.Entity/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.ApiClients.Entity/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Api.ApiClients.Entity/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.ApiClients.Entity/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 994e8e3f..d9f45740 100644 --- a/Api.ApiClients.Entity/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.ApiClients.Entity/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Api.ApiClients.Entity/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Api.ApiClients.Entity/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Api.ApiClients.Entity/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Api.ApiClients.Entity/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.ApiClients.Entity/.claude/skills/nano-remove-entity/SKILL.md b/Api.ApiClients.Entity/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Api.ApiClients.Entity/.claude/skills/nano-remove-entity/SKILL.md +++ b/Api.ApiClients.Entity/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.ApiClients.Entity/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.ApiClients.Entity/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Api.ApiClients.Entity/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.ApiClients.Entity/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 75ed9535..f8345ca6 100644 --- a/Api.ApiClients.Entity/.github/prompts/nano-add-entity.prompt.md +++ b/Api.ApiClients.Entity/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Api.ApiClients.Entity/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api.ApiClients.Entity/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Api.ApiClients.Entity/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Api.ApiClients.Entity/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.ApiClients.Entity/.github/prompts/nano-remove-entity.prompt.md b/Api.ApiClients.Entity/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Api.ApiClients.Entity/.github/prompts/nano-remove-entity.prompt.md +++ b/Api.ApiClients.Entity/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.ApiClients.Entity/AGENTS.md b/Api.ApiClients.Entity/AGENTS.md index 26f63772..3805edf2 100644 --- a/Api.ApiClients.Entity/AGENTS.md +++ b/Api.ApiClients.Entity/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Api.ApiClients.RootLogIn/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.ApiClients.RootLogIn/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Api.ApiClients.RootLogIn/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.ApiClients.RootLogIn/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 994e8e3f..d9f45740 100644 --- a/Api.ApiClients.RootLogIn/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.ApiClients.RootLogIn/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Api.ApiClients.RootLogIn/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Api.ApiClients.RootLogIn/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Api.ApiClients.RootLogIn/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Api.ApiClients.RootLogIn/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.ApiClients.RootLogIn/.claude/skills/nano-remove-entity/SKILL.md b/Api.ApiClients.RootLogIn/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Api.ApiClients.RootLogIn/.claude/skills/nano-remove-entity/SKILL.md +++ b/Api.ApiClients.RootLogIn/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.ApiClients.RootLogIn/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Api.ApiClients.RootLogIn/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 75ed9535..f8345ca6 100644 --- a/Api.ApiClients.RootLogIn/.github/prompts/nano-add-entity.prompt.md +++ b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Api.ApiClients.RootLogIn/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api.ApiClients.RootLogIn/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Api.ApiClients.RootLogIn/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Api.ApiClients.RootLogIn/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.ApiClients.RootLogIn/.github/prompts/nano-remove-entity.prompt.md b/Api.ApiClients.RootLogIn/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Api.ApiClients.RootLogIn/.github/prompts/nano-remove-entity.prompt.md +++ b/Api.ApiClients.RootLogIn/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.ApiClients.RootLogIn/AGENTS.md b/Api.ApiClients.RootLogIn/AGENTS.md index 26f63772..3805edf2 100644 --- a/Api.ApiClients.RootLogIn/AGENTS.md +++ b/Api.ApiClients.RootLogIn/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Api.ApiClients/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.ApiClients/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Api.ApiClients/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.ApiClients/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 100644 --- a/Api.ApiClients/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.ApiClients/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller diff --git a/Api.ApiClients/.claude/skills/nano-add-entity/SKILL.md b/Api.ApiClients/.claude/skills/nano-add-entity/SKILL.md index 994e8e3f..d9f45740 100644 --- a/Api.ApiClients/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.ApiClients/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 100644 --- a/Api.ApiClients/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.ApiClients/.claude/skills/nano-add-postman-collection/SKILL.md @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Api.ApiClients/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Api.ApiClients/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Api.ApiClients/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Api.ApiClients/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.ApiClients/.claude/skills/nano-remove-entity/SKILL.md b/Api.ApiClients/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Api.ApiClients/.claude/skills/nano-remove-entity/SKILL.md +++ b/Api.ApiClients/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.ApiClients/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.ApiClients/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Api.ApiClients/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.ApiClients/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 100644 --- a/Api.ApiClients/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.ApiClients/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller diff --git a/Api.ApiClients/.github/prompts/nano-add-entity.prompt.md b/Api.ApiClients/.github/prompts/nano-add-entity.prompt.md index 75ed9535..f8345ca6 100644 --- a/Api.ApiClients/.github/prompts/nano-add-entity.prompt.md +++ b/Api.ApiClients/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 100644 --- a/Api.ApiClients/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.ApiClients/.github/prompts/nano-add-postman-collection.prompt.md @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Api.ApiClients/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api.ApiClients/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Api.ApiClients/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Api.ApiClients/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.ApiClients/.github/prompts/nano-remove-entity.prompt.md b/Api.ApiClients/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Api.ApiClients/.github/prompts/nano-remove-entity.prompt.md +++ b/Api.ApiClients/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.ApiClients/AGENTS.md b/Api.ApiClients/AGENTS.md index 26f63772..3805edf2 100644 --- a/Api.ApiClients/AGENTS.md +++ b/Api.ApiClients/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Api.Auth.External.Custom/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Auth.External.Custom/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Api.Auth.External.Custom/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Auth.External.Custom/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 994e8e3f..d9f45740 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 @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Auth.External.Custom/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Api.Auth.External.Custom/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Api.Auth.External.Custom/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Api.Auth.External.Custom/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Auth.External.Custom/.claude/skills/nano-remove-entity/SKILL.md b/Api.Auth.External.Custom/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Api.Auth.External.Custom/.claude/skills/nano-remove-entity/SKILL.md +++ b/Api.Auth.External.Custom/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Auth.External.Custom/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Auth.External.Custom/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Api.Auth.External.Custom/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Auth.External.Custom/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 75ed9535..f8345ca6 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 @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Auth.External.Custom/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api.Auth.External.Custom/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Api.Auth.External.Custom/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Api.Auth.External.Custom/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Auth.External.Custom/.github/prompts/nano-remove-entity.prompt.md b/Api.Auth.External.Custom/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Api.Auth.External.Custom/.github/prompts/nano-remove-entity.prompt.md +++ b/Api.Auth.External.Custom/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Auth.External.Custom/AGENTS.md b/Api.Auth.External.Custom/AGENTS.md index 26f63772..3805edf2 100644 --- a/Api.Auth.External.Custom/AGENTS.md +++ b/Api.Auth.External.Custom/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Api.Auth.External.Microsoft/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Auth.External.Microsoft/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Api.Auth.External.Microsoft/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Auth.External.Microsoft/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 994e8e3f..d9f45740 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 @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Auth.External.Microsoft/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Api.Auth.External.Microsoft/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Api.Auth.External.Microsoft/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Api.Auth.External.Microsoft/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Auth.External.Microsoft/.claude/skills/nano-remove-entity/SKILL.md b/Api.Auth.External.Microsoft/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Api.Auth.External.Microsoft/.claude/skills/nano-remove-entity/SKILL.md +++ b/Api.Auth.External.Microsoft/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Auth.External.Microsoft/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Auth.External.Microsoft/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Api.Auth.External.Microsoft/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Auth.External.Microsoft/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 75ed9535..f8345ca6 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 @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Auth.External.Microsoft/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api.Auth.External.Microsoft/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Api.Auth.External.Microsoft/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Api.Auth.External.Microsoft/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Auth.External.Microsoft/.github/prompts/nano-remove-entity.prompt.md b/Api.Auth.External.Microsoft/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Api.Auth.External.Microsoft/.github/prompts/nano-remove-entity.prompt.md +++ b/Api.Auth.External.Microsoft/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Auth.External.Microsoft/.github/workflows/build-and-deploy.yml b/Api.Auth.External.Microsoft/.github/workflows/build-and-deploy.yml index e47fd126..73185b52 100644 --- a/Api.Auth.External.Microsoft/.github/workflows/build-and-deploy.yml +++ b/Api.Auth.External.Microsoft/.github/workflows/build-and-deploy.yml @@ -126,8 +126,8 @@ jobs: run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); - $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = "AzureADMyOrg"; - $env:AUTH_MICROSOFT_TENANT_ID = $env:AZURE_TENANT_ID; + $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = "AzureADandPersonalMicrosoftAccount"; + $env:AUTH_MICROSOFT_TENANT_ID = "common"; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -135,6 +135,7 @@ jobs: az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -144,6 +145,7 @@ jobs: az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } diff --git a/Api.Auth.External.Microsoft/AGENTS.md b/Api.Auth.External.Microsoft/AGENTS.md index 26f63772..3805edf2 100644 --- a/Api.Auth.External.Microsoft/AGENTS.md +++ b/Api.Auth.External.Microsoft/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Api.Auth.External.Microsoft/README.md b/Api.Auth.External.Microsoft/README.md index 1b92832e..a2eaf759 100644 --- a/Api.Auth.External.Microsoft/README.md +++ b/Api.Auth.External.Microsoft/README.md @@ -43,13 +43,13 @@ The following endpoint from the auth controller is available for testing. Getting an authorization `code` to actually call the login endpoint above needs a real Microsoft sign-in redirect first. Paste the following into a browser tab (matching the `Azure setup` app registration and Postman collection this lesson ships with): ``` -https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/authorize +https://login.microsoftonline.com/common/oauth2/v2.0/authorize ?client_id={ClientId} &response_type=code - &redirect_uri=http://localhost/auth/callback + &redirect_uri=http://localhost/auth &response_mode=query &scope=openid profile email offline_access - &code_challenge=7pllenK7L-Ys_Xa-5l7QUxXsSTjFTrEgKcXyx_0eBkM + &code_challenge=d8u9K3bpfN5jJTaQxkiQ33ippLXy-OONYvhVMFumhNY &code_challenge_method=S256 &state=baa2d418-953d-4e46-b4ce-b0aeb89da6f9 ``` @@ -74,15 +74,16 @@ new client secret and can create a duplicate app registration. ```powershell $env:APP_DISPLAY_NAME = "nano-api-auth-external-microsoft"; -$env:REDIRECT_URI = "http://localhost/auth/callback"; +$env:REDIRECT_URI = "http://localhost/auth"; $env:SECRET_DISPLAY_NAME = "nano-lesson-secret"; -$env:TENANT_ID = az account show --query "tenantId" -o tsv; +$env:TENANT_ID = "common"; $env:APP_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience AzureADMyOrg ` + --sign-in-audience AzureADandPersonalMicrosoftAccount ` + --requested-access-token-version 2 ` --web-redirect-uris $env:REDIRECT_URI; $env:CLIENT_SECRET = az ad app credential reset ` @@ -95,6 +96,8 @@ $env:CLIENT_SECRET = az ad app credential reset ` The default `User.Read` delegated permission (present on every new app registration) is enough for this lesson, the scopes below (`openid`, `profile`, `email` and `offline_access`) don't require any Graph API permission at all, since they only control what ends up in the `id_token`, not access to any resource. +The app registration is created with sign-in audience `AzureADandPersonalMicrosoftAccount` (any work/school or personal Microsoft account), which is why the authorize URL above uses `common` as the tenant, and why `TenantId` in the configuration below is the literal `common` rather than a real tenant GUID. Personal accounts also require access token version 2, set with `--requested-access-token-version 2`. + > ⚠️ The client secret is a real credential for your own Azure tenant. Don't commit a real value into `appsettings.Development.json`, copy the script's output in locally, and keep it out of source control (e.g. via `dotnet user-secrets`, or simply not staging the change). @@ -222,7 +225,8 @@ env: { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience AzureADMyOrg ` + --sign-in-audience AzureADandPersonalMicrosoftAccount ` + --requested-access-token-version 2 ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; diff --git a/Api.Auth.RootLogin/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Auth.RootLogin/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Api.Auth.RootLogin/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Auth.RootLogin/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 994e8e3f..d9f45740 100644 --- a/Api.Auth.RootLogin/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Auth.RootLogin/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Auth.RootLogin/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Api.Auth.RootLogin/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Api.Auth.RootLogin/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Api.Auth.RootLogin/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Auth.RootLogin/.claude/skills/nano-remove-entity/SKILL.md b/Api.Auth.RootLogin/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Api.Auth.RootLogin/.claude/skills/nano-remove-entity/SKILL.md +++ b/Api.Auth.RootLogin/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Auth.RootLogin/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Auth.RootLogin/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Api.Auth.RootLogin/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Auth.RootLogin/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 75ed9535..f8345ca6 100644 --- a/Api.Auth.RootLogin/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Auth.RootLogin/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Auth.RootLogin/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api.Auth.RootLogin/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Api.Auth.RootLogin/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Api.Auth.RootLogin/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Auth.RootLogin/.github/prompts/nano-remove-entity.prompt.md b/Api.Auth.RootLogin/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Api.Auth.RootLogin/.github/prompts/nano-remove-entity.prompt.md +++ b/Api.Auth.RootLogin/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Auth.RootLogin/AGENTS.md b/Api.Auth.RootLogin/AGENTS.md index 26f63772..3805edf2 100644 --- a/Api.Auth.RootLogin/AGENTS.md +++ b/Api.Auth.RootLogin/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Api.Authorization/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Authorization/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Api.Authorization/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Authorization/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 100644 --- a/Api.Authorization/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.Authorization/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller diff --git a/Api.Authorization/.claude/skills/nano-add-entity/SKILL.md b/Api.Authorization/.claude/skills/nano-add-entity/SKILL.md index 994e8e3f..d9f45740 100644 --- a/Api.Authorization/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Authorization/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 100644 --- a/Api.Authorization/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Authorization/.claude/skills/nano-add-postman-collection/SKILL.md @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Authorization/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Api.Authorization/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Api.Authorization/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Api.Authorization/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Authorization/.claude/skills/nano-remove-entity/SKILL.md b/Api.Authorization/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Api.Authorization/.claude/skills/nano-remove-entity/SKILL.md +++ b/Api.Authorization/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Authorization/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Authorization/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Api.Authorization/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Authorization/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 100644 --- a/Api.Authorization/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Authorization/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller diff --git a/Api.Authorization/.github/prompts/nano-add-entity.prompt.md b/Api.Authorization/.github/prompts/nano-add-entity.prompt.md index 75ed9535..f8345ca6 100644 --- a/Api.Authorization/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Authorization/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 100644 --- a/Api.Authorization/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Authorization/.github/prompts/nano-add-postman-collection.prompt.md @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Authorization/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api.Authorization/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Api.Authorization/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Api.Authorization/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Authorization/.github/prompts/nano-remove-entity.prompt.md b/Api.Authorization/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Api.Authorization/.github/prompts/nano-remove-entity.prompt.md +++ b/Api.Authorization/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Authorization/AGENTS.md b/Api.Authorization/AGENTS.md index 26f63772..3805edf2 100644 --- a/Api.Authorization/AGENTS.md +++ b/Api.Authorization/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Api.ContentNegotiation/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.ContentNegotiation/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Api.ContentNegotiation/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.ContentNegotiation/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 100644 --- a/Api.ContentNegotiation/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.ContentNegotiation/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller diff --git a/Api.ContentNegotiation/.claude/skills/nano-add-entity/SKILL.md b/Api.ContentNegotiation/.claude/skills/nano-add-entity/SKILL.md index 994e8e3f..d9f45740 100644 --- a/Api.ContentNegotiation/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.ContentNegotiation/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 100644 --- a/Api.ContentNegotiation/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.ContentNegotiation/.claude/skills/nano-add-postman-collection/SKILL.md @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Api.ContentNegotiation/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Api.ContentNegotiation/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Api.ContentNegotiation/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Api.ContentNegotiation/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.ContentNegotiation/.claude/skills/nano-remove-entity/SKILL.md b/Api.ContentNegotiation/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Api.ContentNegotiation/.claude/skills/nano-remove-entity/SKILL.md +++ b/Api.ContentNegotiation/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.ContentNegotiation/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.ContentNegotiation/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Api.ContentNegotiation/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.ContentNegotiation/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 100644 --- a/Api.ContentNegotiation/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.ContentNegotiation/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller diff --git a/Api.ContentNegotiation/.github/prompts/nano-add-entity.prompt.md b/Api.ContentNegotiation/.github/prompts/nano-add-entity.prompt.md index 75ed9535..f8345ca6 100644 --- a/Api.ContentNegotiation/.github/prompts/nano-add-entity.prompt.md +++ b/Api.ContentNegotiation/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 100644 --- a/Api.ContentNegotiation/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.ContentNegotiation/.github/prompts/nano-add-postman-collection.prompt.md @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Api.ContentNegotiation/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api.ContentNegotiation/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Api.ContentNegotiation/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Api.ContentNegotiation/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.ContentNegotiation/.github/prompts/nano-remove-entity.prompt.md b/Api.ContentNegotiation/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Api.ContentNegotiation/.github/prompts/nano-remove-entity.prompt.md +++ b/Api.ContentNegotiation/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.ContentNegotiation/AGENTS.md b/Api.ContentNegotiation/AGENTS.md index 26f63772..3805edf2 100644 --- a/Api.ContentNegotiation/AGENTS.md +++ b/Api.ContentNegotiation/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Api.Cookies/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Cookies/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Api.Cookies/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Cookies/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 100644 --- a/Api.Cookies/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.Cookies/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller diff --git a/Api.Cookies/.claude/skills/nano-add-entity/SKILL.md b/Api.Cookies/.claude/skills/nano-add-entity/SKILL.md index 994e8e3f..d9f45740 100644 --- a/Api.Cookies/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Cookies/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 100644 --- a/Api.Cookies/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Cookies/.claude/skills/nano-add-postman-collection/SKILL.md @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Cookies/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Api.Cookies/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Api.Cookies/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Api.Cookies/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Cookies/.claude/skills/nano-remove-entity/SKILL.md b/Api.Cookies/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Api.Cookies/.claude/skills/nano-remove-entity/SKILL.md +++ b/Api.Cookies/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Cookies/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Cookies/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Api.Cookies/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Cookies/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 100644 --- a/Api.Cookies/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Cookies/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller diff --git a/Api.Cookies/.github/prompts/nano-add-entity.prompt.md b/Api.Cookies/.github/prompts/nano-add-entity.prompt.md index 75ed9535..f8345ca6 100644 --- a/Api.Cookies/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Cookies/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 100644 --- a/Api.Cookies/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Cookies/.github/prompts/nano-add-postman-collection.prompt.md @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Cookies/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api.Cookies/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Api.Cookies/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Api.Cookies/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Cookies/.github/prompts/nano-remove-entity.prompt.md b/Api.Cookies/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Api.Cookies/.github/prompts/nano-remove-entity.prompt.md +++ b/Api.Cookies/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Cookies/AGENTS.md b/Api.Cookies/AGENTS.md index 26f63772..3805edf2 100644 --- a/Api.Cookies/AGENTS.md +++ b/Api.Cookies/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Api.CustomConfigSection/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.CustomConfigSection/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Api.CustomConfigSection/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.CustomConfigSection/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 100644 --- a/Api.CustomConfigSection/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.CustomConfigSection/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller diff --git a/Api.CustomConfigSection/.claude/skills/nano-add-entity/SKILL.md b/Api.CustomConfigSection/.claude/skills/nano-add-entity/SKILL.md index 994e8e3f..d9f45740 100644 --- a/Api.CustomConfigSection/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.CustomConfigSection/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 100644 --- a/Api.CustomConfigSection/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.CustomConfigSection/.claude/skills/nano-add-postman-collection/SKILL.md @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Api.CustomConfigSection/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Api.CustomConfigSection/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Api.CustomConfigSection/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Api.CustomConfigSection/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.CustomConfigSection/.claude/skills/nano-remove-entity/SKILL.md b/Api.CustomConfigSection/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Api.CustomConfigSection/.claude/skills/nano-remove-entity/SKILL.md +++ b/Api.CustomConfigSection/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.CustomConfigSection/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.CustomConfigSection/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Api.CustomConfigSection/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.CustomConfigSection/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 100644 --- a/Api.CustomConfigSection/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.CustomConfigSection/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller diff --git a/Api.CustomConfigSection/.github/prompts/nano-add-entity.prompt.md b/Api.CustomConfigSection/.github/prompts/nano-add-entity.prompt.md index 75ed9535..f8345ca6 100644 --- a/Api.CustomConfigSection/.github/prompts/nano-add-entity.prompt.md +++ b/Api.CustomConfigSection/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 100644 --- a/Api.CustomConfigSection/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.CustomConfigSection/.github/prompts/nano-add-postman-collection.prompt.md @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Api.CustomConfigSection/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api.CustomConfigSection/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Api.CustomConfigSection/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Api.CustomConfigSection/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.CustomConfigSection/.github/prompts/nano-remove-entity.prompt.md b/Api.CustomConfigSection/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Api.CustomConfigSection/.github/prompts/nano-remove-entity.prompt.md +++ b/Api.CustomConfigSection/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.CustomConfigSection/AGENTS.md b/Api.CustomConfigSection/AGENTS.md index 26f63772..3805edf2 100644 --- a/Api.CustomConfigSection/AGENTS.md +++ b/Api.CustomConfigSection/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Api.CustomMiddleware/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.CustomMiddleware/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Api.CustomMiddleware/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.CustomMiddleware/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 100644 --- a/Api.CustomMiddleware/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.CustomMiddleware/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller diff --git a/Api.CustomMiddleware/.claude/skills/nano-add-entity/SKILL.md b/Api.CustomMiddleware/.claude/skills/nano-add-entity/SKILL.md index 994e8e3f..d9f45740 100644 --- a/Api.CustomMiddleware/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.CustomMiddleware/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 100644 --- a/Api.CustomMiddleware/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.CustomMiddleware/.claude/skills/nano-add-postman-collection/SKILL.md @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Api.CustomMiddleware/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Api.CustomMiddleware/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Api.CustomMiddleware/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Api.CustomMiddleware/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.CustomMiddleware/.claude/skills/nano-remove-entity/SKILL.md b/Api.CustomMiddleware/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Api.CustomMiddleware/.claude/skills/nano-remove-entity/SKILL.md +++ b/Api.CustomMiddleware/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.CustomMiddleware/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.CustomMiddleware/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Api.CustomMiddleware/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.CustomMiddleware/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 100644 --- a/Api.CustomMiddleware/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.CustomMiddleware/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller diff --git a/Api.CustomMiddleware/.github/prompts/nano-add-entity.prompt.md b/Api.CustomMiddleware/.github/prompts/nano-add-entity.prompt.md index 75ed9535..f8345ca6 100644 --- a/Api.CustomMiddleware/.github/prompts/nano-add-entity.prompt.md +++ b/Api.CustomMiddleware/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 100644 --- a/Api.CustomMiddleware/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.CustomMiddleware/.github/prompts/nano-add-postman-collection.prompt.md @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Api.CustomMiddleware/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api.CustomMiddleware/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Api.CustomMiddleware/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Api.CustomMiddleware/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.CustomMiddleware/.github/prompts/nano-remove-entity.prompt.md b/Api.CustomMiddleware/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Api.CustomMiddleware/.github/prompts/nano-remove-entity.prompt.md +++ b/Api.CustomMiddleware/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.CustomMiddleware/AGENTS.md b/Api.CustomMiddleware/AGENTS.md index 26f63772..3805edf2 100644 --- a/Api.CustomMiddleware/AGENTS.md +++ b/Api.CustomMiddleware/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Api.CustomService/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.CustomService/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Api.CustomService/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.CustomService/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 100644 --- a/Api.CustomService/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.CustomService/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller diff --git a/Api.CustomService/.claude/skills/nano-add-entity/SKILL.md b/Api.CustomService/.claude/skills/nano-add-entity/SKILL.md index 994e8e3f..d9f45740 100644 --- a/Api.CustomService/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.CustomService/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 100644 --- a/Api.CustomService/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.CustomService/.claude/skills/nano-add-postman-collection/SKILL.md @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Api.CustomService/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Api.CustomService/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Api.CustomService/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Api.CustomService/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.CustomService/.claude/skills/nano-remove-entity/SKILL.md b/Api.CustomService/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Api.CustomService/.claude/skills/nano-remove-entity/SKILL.md +++ b/Api.CustomService/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.CustomService/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.CustomService/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Api.CustomService/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.CustomService/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 100644 --- a/Api.CustomService/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.CustomService/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller diff --git a/Api.CustomService/.github/prompts/nano-add-entity.prompt.md b/Api.CustomService/.github/prompts/nano-add-entity.prompt.md index 75ed9535..f8345ca6 100644 --- a/Api.CustomService/.github/prompts/nano-add-entity.prompt.md +++ b/Api.CustomService/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 100644 --- a/Api.CustomService/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.CustomService/.github/prompts/nano-add-postman-collection.prompt.md @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Api.CustomService/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api.CustomService/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Api.CustomService/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Api.CustomService/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.CustomService/.github/prompts/nano-remove-entity.prompt.md b/Api.CustomService/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Api.CustomService/.github/prompts/nano-remove-entity.prompt.md +++ b/Api.CustomService/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.CustomService/AGENTS.md b/Api.CustomService/AGENTS.md index 26f63772..3805edf2 100644 --- a/Api.CustomService/AGENTS.md +++ b/Api.CustomService/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Api.Data.Audit/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Data.Audit/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Api.Data.Audit/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Data.Audit/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 994e8e3f..d9f45740 100644 --- a/Api.Data.Audit/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.Audit/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Data.Audit/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Api.Data.Audit/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Api.Data.Audit/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Api.Data.Audit/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Data.Audit/.claude/skills/nano-remove-entity/SKILL.md b/Api.Data.Audit/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Api.Data.Audit/.claude/skills/nano-remove-entity/SKILL.md +++ b/Api.Data.Audit/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Data.Audit/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Data.Audit/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Api.Data.Audit/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Data.Audit/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 75ed9535..f8345ca6 100644 --- a/Api.Data.Audit/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.Audit/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Data.Audit/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api.Data.Audit/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Api.Data.Audit/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Api.Data.Audit/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Data.Audit/.github/prompts/nano-remove-entity.prompt.md b/Api.Data.Audit/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Api.Data.Audit/.github/prompts/nano-remove-entity.prompt.md +++ b/Api.Data.Audit/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Data.Audit/AGENTS.md b/Api.Data.Audit/AGENTS.md index 26f63772..3805edf2 100644 --- a/Api.Data.Audit/AGENTS.md +++ b/Api.Data.Audit/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Api.Data.EntityEvents/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Data.EntityEvents/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Api.Data.EntityEvents/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Data.EntityEvents/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 994e8e3f..d9f45740 100644 --- a/Api.Data.EntityEvents/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.EntityEvents/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Data.EntityEvents/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Api.Data.EntityEvents/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Api.Data.EntityEvents/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Api.Data.EntityEvents/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Data.EntityEvents/.claude/skills/nano-remove-entity/SKILL.md b/Api.Data.EntityEvents/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Api.Data.EntityEvents/.claude/skills/nano-remove-entity/SKILL.md +++ b/Api.Data.EntityEvents/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Data.EntityEvents/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Data.EntityEvents/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Api.Data.EntityEvents/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Data.EntityEvents/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 75ed9535..f8345ca6 100644 --- a/Api.Data.EntityEvents/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.EntityEvents/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Data.EntityEvents/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api.Data.EntityEvents/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Api.Data.EntityEvents/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Api.Data.EntityEvents/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Data.EntityEvents/.github/prompts/nano-remove-entity.prompt.md b/Api.Data.EntityEvents/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Api.Data.EntityEvents/.github/prompts/nano-remove-entity.prompt.md +++ b/Api.Data.EntityEvents/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Data.EntityEvents/AGENTS.md b/Api.Data.EntityEvents/AGENTS.md index 26f63772..3805edf2 100644 --- a/Api.Data.EntityEvents/AGENTS.md +++ b/Api.Data.EntityEvents/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 994e8e3f..d9f45740 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 @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-remove-entity/SKILL.md b/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-remove-entity/SKILL.md +++ b/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 75ed9535..f8345ca6 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 @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-remove-entity.prompt.md b/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-remove-entity.prompt.md +++ b/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Data.Identity.Auth.ApiKey/AGENTS.md b/Api.Data.Identity.Auth.ApiKey/AGENTS.md index 26f63772..3805edf2 100644 --- a/Api.Data.Identity.Auth.ApiKey/AGENTS.md +++ b/Api.Data.Identity.Auth.ApiKey/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 994e8e3f..d9f45740 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 @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-remove-entity/SKILL.md b/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-remove-entity/SKILL.md +++ b/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 75ed9535..f8345ca6 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 @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-remove-entity.prompt.md b/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-remove-entity.prompt.md +++ b/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Data.Identity.Auth.External.Custom/AGENTS.md b/Api.Data.Identity.Auth.External.Custom/AGENTS.md index 26f63772..3805edf2 100644 --- a/Api.Data.Identity.Auth.External.Custom/AGENTS.md +++ b/Api.Data.Identity.Auth.External.Custom/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 994e8e3f..d9f45740 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 @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-remove-entity/SKILL.md b/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-remove-entity/SKILL.md +++ b/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 75ed9535..f8345ca6 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 @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-remove-entity.prompt.md b/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-remove-entity.prompt.md +++ b/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Data.Identity.Auth.Jwt/AGENTS.md b/Api.Data.Identity.Auth.Jwt/AGENTS.md index 26f63772..3805edf2 100644 --- a/Api.Data.Identity.Auth.Jwt/AGENTS.md +++ b/Api.Data.Identity.Auth.Jwt/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Api.Data.Identity/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Data.Identity/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Api.Data.Identity/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Data.Identity/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 994e8e3f..d9f45740 100644 --- a/Api.Data.Identity/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.Identity/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Data.Identity/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Api.Data.Identity/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Api.Data.Identity/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Api.Data.Identity/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Data.Identity/.claude/skills/nano-remove-entity/SKILL.md b/Api.Data.Identity/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Api.Data.Identity/.claude/skills/nano-remove-entity/SKILL.md +++ b/Api.Data.Identity/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Data.Identity/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Data.Identity/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Api.Data.Identity/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Data.Identity/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 75ed9535..f8345ca6 100644 --- a/Api.Data.Identity/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.Identity/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Data.Identity/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api.Data.Identity/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Api.Data.Identity/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Api.Data.Identity/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Data.Identity/.github/prompts/nano-remove-entity.prompt.md b/Api.Data.Identity/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Api.Data.Identity/.github/prompts/nano-remove-entity.prompt.md +++ b/Api.Data.Identity/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Data.Identity/AGENTS.md b/Api.Data.Identity/AGENTS.md index 26f63772..3805edf2 100644 --- a/Api.Data.Identity/AGENTS.md +++ b/Api.Data.Identity/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Api.Data.InMemory/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Data.InMemory/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Api.Data.InMemory/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Data.InMemory/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 994e8e3f..d9f45740 100644 --- a/Api.Data.InMemory/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.InMemory/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Data.InMemory/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Api.Data.InMemory/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Api.Data.InMemory/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Api.Data.InMemory/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Data.InMemory/.claude/skills/nano-remove-entity/SKILL.md b/Api.Data.InMemory/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Api.Data.InMemory/.claude/skills/nano-remove-entity/SKILL.md +++ b/Api.Data.InMemory/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Data.InMemory/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Data.InMemory/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Api.Data.InMemory/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Data.InMemory/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 75ed9535..f8345ca6 100644 --- a/Api.Data.InMemory/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.InMemory/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Data.InMemory/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api.Data.InMemory/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Api.Data.InMemory/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Api.Data.InMemory/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Data.InMemory/.github/prompts/nano-remove-entity.prompt.md b/Api.Data.InMemory/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Api.Data.InMemory/.github/prompts/nano-remove-entity.prompt.md +++ b/Api.Data.InMemory/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Data.InMemory/AGENTS.md b/Api.Data.InMemory/AGENTS.md index 26f63772..3805edf2 100644 --- a/Api.Data.InMemory/AGENTS.md +++ b/Api.Data.InMemory/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Api.Data.LazyLoading/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Data.LazyLoading/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Api.Data.LazyLoading/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Data.LazyLoading/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 994e8e3f..d9f45740 100644 --- a/Api.Data.LazyLoading/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.LazyLoading/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Data.LazyLoading/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Api.Data.LazyLoading/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Api.Data.LazyLoading/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Api.Data.LazyLoading/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Data.LazyLoading/.claude/skills/nano-remove-entity/SKILL.md b/Api.Data.LazyLoading/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Api.Data.LazyLoading/.claude/skills/nano-remove-entity/SKILL.md +++ b/Api.Data.LazyLoading/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Data.LazyLoading/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Data.LazyLoading/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Api.Data.LazyLoading/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Data.LazyLoading/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 75ed9535..f8345ca6 100644 --- a/Api.Data.LazyLoading/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.LazyLoading/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Data.LazyLoading/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api.Data.LazyLoading/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Api.Data.LazyLoading/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Api.Data.LazyLoading/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Data.LazyLoading/.github/prompts/nano-remove-entity.prompt.md b/Api.Data.LazyLoading/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Api.Data.LazyLoading/.github/prompts/nano-remove-entity.prompt.md +++ b/Api.Data.LazyLoading/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Data.LazyLoading/AGENTS.md b/Api.Data.LazyLoading/AGENTS.md index 26f63772..3805edf2 100644 --- a/Api.Data.LazyLoading/AGENTS.md +++ b/Api.Data.LazyLoading/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Api.Data.MySql.Collation/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Data.MySql.Collation/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Api.Data.MySql.Collation/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Data.MySql.Collation/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 994e8e3f..d9f45740 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 @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Data.MySql.Collation/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Api.Data.MySql.Collation/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Api.Data.MySql.Collation/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Api.Data.MySql.Collation/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Data.MySql.Collation/.claude/skills/nano-remove-entity/SKILL.md b/Api.Data.MySql.Collation/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Api.Data.MySql.Collation/.claude/skills/nano-remove-entity/SKILL.md +++ b/Api.Data.MySql.Collation/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Data.MySql.Collation/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Data.MySql.Collation/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Api.Data.MySql.Collation/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Data.MySql.Collation/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 75ed9535..f8345ca6 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 @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Data.MySql.Collation/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api.Data.MySql.Collation/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Api.Data.MySql.Collation/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Api.Data.MySql.Collation/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Data.MySql.Collation/.github/prompts/nano-remove-entity.prompt.md b/Api.Data.MySql.Collation/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Api.Data.MySql.Collation/.github/prompts/nano-remove-entity.prompt.md +++ b/Api.Data.MySql.Collation/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Data.MySql.Collation/AGENTS.md b/Api.Data.MySql.Collation/AGENTS.md index 26f63772..3805edf2 100644 --- a/Api.Data.MySql.Collation/AGENTS.md +++ b/Api.Data.MySql.Collation/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Api.Data.MySql.Mappings/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Data.MySql.Mappings/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Api.Data.MySql.Mappings/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Data.MySql.Mappings/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 994e8e3f..d9f45740 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 @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Data.MySql.Mappings/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Api.Data.MySql.Mappings/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Api.Data.MySql.Mappings/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Api.Data.MySql.Mappings/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Data.MySql.Mappings/.claude/skills/nano-remove-entity/SKILL.md b/Api.Data.MySql.Mappings/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Api.Data.MySql.Mappings/.claude/skills/nano-remove-entity/SKILL.md +++ b/Api.Data.MySql.Mappings/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Data.MySql.Mappings/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Data.MySql.Mappings/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Api.Data.MySql.Mappings/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Data.MySql.Mappings/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 75ed9535..f8345ca6 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 @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Data.MySql.Mappings/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api.Data.MySql.Mappings/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Api.Data.MySql.Mappings/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Api.Data.MySql.Mappings/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Data.MySql.Mappings/.github/prompts/nano-remove-entity.prompt.md b/Api.Data.MySql.Mappings/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Api.Data.MySql.Mappings/.github/prompts/nano-remove-entity.prompt.md +++ b/Api.Data.MySql.Mappings/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Data.MySql.Mappings/AGENTS.md b/Api.Data.MySql.Mappings/AGENTS.md index 26f63772..3805edf2 100644 --- a/Api.Data.MySql.Mappings/AGENTS.md +++ b/Api.Data.MySql.Mappings/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Api.Data.MySql.Spatial/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Data.MySql.Spatial/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Api.Data.MySql.Spatial/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Data.MySql.Spatial/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 994e8e3f..d9f45740 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 @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Data.MySql.Spatial/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Api.Data.MySql.Spatial/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Api.Data.MySql.Spatial/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Api.Data.MySql.Spatial/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Data.MySql.Spatial/.claude/skills/nano-remove-entity/SKILL.md b/Api.Data.MySql.Spatial/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Api.Data.MySql.Spatial/.claude/skills/nano-remove-entity/SKILL.md +++ b/Api.Data.MySql.Spatial/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Data.MySql.Spatial/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Data.MySql.Spatial/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Api.Data.MySql.Spatial/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Data.MySql.Spatial/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 75ed9535..f8345ca6 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 @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Data.MySql.Spatial/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api.Data.MySql.Spatial/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Api.Data.MySql.Spatial/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Api.Data.MySql.Spatial/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Data.MySql.Spatial/.github/prompts/nano-remove-entity.prompt.md b/Api.Data.MySql.Spatial/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Api.Data.MySql.Spatial/.github/prompts/nano-remove-entity.prompt.md +++ b/Api.Data.MySql.Spatial/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Data.MySql.Spatial/AGENTS.md b/Api.Data.MySql.Spatial/AGENTS.md index 26f63772..3805edf2 100644 --- a/Api.Data.MySql.Spatial/AGENTS.md +++ b/Api.Data.MySql.Spatial/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 994e8e3f..d9f45740 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 @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Data.MySql.StoredProcedures/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Api.Data.MySql.StoredProcedures/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Api.Data.MySql.StoredProcedures/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Api.Data.MySql.StoredProcedures/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Data.MySql.StoredProcedures/.claude/skills/nano-remove-entity/SKILL.md b/Api.Data.MySql.StoredProcedures/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Api.Data.MySql.StoredProcedures/.claude/skills/nano-remove-entity/SKILL.md +++ b/Api.Data.MySql.StoredProcedures/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 75ed9535..f8345ca6 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 @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Data.MySql.StoredProcedures/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api.Data.MySql.StoredProcedures/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Api.Data.MySql.StoredProcedures/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Api.Data.MySql.StoredProcedures/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Data.MySql.StoredProcedures/.github/prompts/nano-remove-entity.prompt.md b/Api.Data.MySql.StoredProcedures/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Api.Data.MySql.StoredProcedures/.github/prompts/nano-remove-entity.prompt.md +++ b/Api.Data.MySql.StoredProcedures/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Data.MySql.StoredProcedures/AGENTS.md b/Api.Data.MySql.StoredProcedures/AGENTS.md index 26f63772..3805edf2 100644 --- a/Api.Data.MySql.StoredProcedures/AGENTS.md +++ b/Api.Data.MySql.StoredProcedures/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Api.Data.MySql.Views/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Data.MySql.Views/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Api.Data.MySql.Views/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Data.MySql.Views/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 994e8e3f..d9f45740 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 @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Data.MySql.Views/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Api.Data.MySql.Views/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Api.Data.MySql.Views/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Api.Data.MySql.Views/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Data.MySql.Views/.claude/skills/nano-remove-entity/SKILL.md b/Api.Data.MySql.Views/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Api.Data.MySql.Views/.claude/skills/nano-remove-entity/SKILL.md +++ b/Api.Data.MySql.Views/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Data.MySql.Views/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Data.MySql.Views/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Api.Data.MySql.Views/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Data.MySql.Views/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 75ed9535..f8345ca6 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 @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Data.MySql.Views/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api.Data.MySql.Views/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Api.Data.MySql.Views/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Api.Data.MySql.Views/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Data.MySql.Views/.github/prompts/nano-remove-entity.prompt.md b/Api.Data.MySql.Views/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Api.Data.MySql.Views/.github/prompts/nano-remove-entity.prompt.md +++ b/Api.Data.MySql.Views/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Data.MySql.Views/AGENTS.md b/Api.Data.MySql.Views/AGENTS.md index 26f63772..3805edf2 100644 --- a/Api.Data.MySql.Views/AGENTS.md +++ b/Api.Data.MySql.Views/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Api.Data.MySql/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Data.MySql/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Api.Data.MySql/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Data.MySql/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 994e8e3f..d9f45740 100644 --- a/Api.Data.MySql/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.MySql/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Data.MySql/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Api.Data.MySql/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Api.Data.MySql/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Api.Data.MySql/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Data.MySql/.claude/skills/nano-remove-entity/SKILL.md b/Api.Data.MySql/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Api.Data.MySql/.claude/skills/nano-remove-entity/SKILL.md +++ b/Api.Data.MySql/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Data.MySql/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Data.MySql/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Api.Data.MySql/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Data.MySql/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 75ed9535..f8345ca6 100644 --- a/Api.Data.MySql/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.MySql/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Data.MySql/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api.Data.MySql/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Api.Data.MySql/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Api.Data.MySql/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Data.MySql/.github/prompts/nano-remove-entity.prompt.md b/Api.Data.MySql/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Api.Data.MySql/.github/prompts/nano-remove-entity.prompt.md +++ b/Api.Data.MySql/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Data.MySql/AGENTS.md b/Api.Data.MySql/AGENTS.md index 26f63772..3805edf2 100644 --- a/Api.Data.MySql/AGENTS.md +++ b/Api.Data.MySql/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 994e8e3f..d9f45740 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 @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-remove-entity/SKILL.md b/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-remove-entity/SKILL.md +++ b/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 75ed9535..f8345ca6 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 @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-remove-entity.prompt.md b/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-remove-entity.prompt.md +++ b/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Data.PostgreSQL.Spatial/AGENTS.md b/Api.Data.PostgreSQL.Spatial/AGENTS.md index 26f63772..3805edf2 100644 --- a/Api.Data.PostgreSQL.Spatial/AGENTS.md +++ b/Api.Data.PostgreSQL.Spatial/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Api.Data.PostgreSQL/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Data.PostgreSQL/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Api.Data.PostgreSQL/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Data.PostgreSQL/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 994e8e3f..d9f45740 100644 --- a/Api.Data.PostgreSQL/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.PostgreSQL/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Data.PostgreSQL/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Api.Data.PostgreSQL/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Api.Data.PostgreSQL/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Api.Data.PostgreSQL/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Data.PostgreSQL/.claude/skills/nano-remove-entity/SKILL.md b/Api.Data.PostgreSQL/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Api.Data.PostgreSQL/.claude/skills/nano-remove-entity/SKILL.md +++ b/Api.Data.PostgreSQL/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Data.PostgreSQL/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Data.PostgreSQL/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Api.Data.PostgreSQL/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Data.PostgreSQL/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 75ed9535..f8345ca6 100644 --- a/Api.Data.PostgreSQL/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.PostgreSQL/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Data.PostgreSQL/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api.Data.PostgreSQL/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Api.Data.PostgreSQL/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Api.Data.PostgreSQL/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Data.PostgreSQL/.github/prompts/nano-remove-entity.prompt.md b/Api.Data.PostgreSQL/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Api.Data.PostgreSQL/.github/prompts/nano-remove-entity.prompt.md +++ b/Api.Data.PostgreSQL/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Data.PostgreSQL/AGENTS.md b/Api.Data.PostgreSQL/AGENTS.md index 26f63772..3805edf2 100644 --- a/Api.Data.PostgreSQL/AGENTS.md +++ b/Api.Data.PostgreSQL/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Api.Data.Repository.AutoSave/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Data.Repository.AutoSave/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Api.Data.Repository.AutoSave/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Data.Repository.AutoSave/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 994e8e3f..d9f45740 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 @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Data.Repository.AutoSave/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Api.Data.Repository.AutoSave/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Api.Data.Repository.AutoSave/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Api.Data.Repository.AutoSave/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Data.Repository.AutoSave/.claude/skills/nano-remove-entity/SKILL.md b/Api.Data.Repository.AutoSave/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Api.Data.Repository.AutoSave/.claude/skills/nano-remove-entity/SKILL.md +++ b/Api.Data.Repository.AutoSave/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Data.Repository.AutoSave/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Data.Repository.AutoSave/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Api.Data.Repository.AutoSave/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Data.Repository.AutoSave/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 75ed9535..f8345ca6 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 @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Data.Repository.AutoSave/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api.Data.Repository.AutoSave/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Api.Data.Repository.AutoSave/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Api.Data.Repository.AutoSave/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Data.Repository.AutoSave/.github/prompts/nano-remove-entity.prompt.md b/Api.Data.Repository.AutoSave/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Api.Data.Repository.AutoSave/.github/prompts/nano-remove-entity.prompt.md +++ b/Api.Data.Repository.AutoSave/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Data.Repository.AutoSave/AGENTS.md b/Api.Data.Repository.AutoSave/AGENTS.md index 26f63772..3805edf2 100644 --- a/Api.Data.Repository.AutoSave/AGENTS.md +++ b/Api.Data.Repository.AutoSave/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Api.Data.Repository.Includes/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Data.Repository.Includes/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Api.Data.Repository.Includes/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Data.Repository.Includes/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 994e8e3f..d9f45740 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 @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Data.Repository.Includes/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Api.Data.Repository.Includes/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Api.Data.Repository.Includes/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Api.Data.Repository.Includes/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Data.Repository.Includes/.claude/skills/nano-remove-entity/SKILL.md b/Api.Data.Repository.Includes/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Api.Data.Repository.Includes/.claude/skills/nano-remove-entity/SKILL.md +++ b/Api.Data.Repository.Includes/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Data.Repository.Includes/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Data.Repository.Includes/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Api.Data.Repository.Includes/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Data.Repository.Includes/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 75ed9535..f8345ca6 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 @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Data.Repository.Includes/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api.Data.Repository.Includes/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Api.Data.Repository.Includes/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Api.Data.Repository.Includes/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Data.Repository.Includes/.github/prompts/nano-remove-entity.prompt.md b/Api.Data.Repository.Includes/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Api.Data.Repository.Includes/.github/prompts/nano-remove-entity.prompt.md +++ b/Api.Data.Repository.Includes/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Data.Repository.Includes/AGENTS.md b/Api.Data.Repository.Includes/AGENTS.md index 26f63772..3805edf2 100644 --- a/Api.Data.Repository.Includes/AGENTS.md +++ b/Api.Data.Repository.Includes/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Api.Data.SoftDelete/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Data.SoftDelete/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Api.Data.SoftDelete/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Data.SoftDelete/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 994e8e3f..d9f45740 100644 --- a/Api.Data.SoftDelete/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.SoftDelete/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Data.SoftDelete/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Api.Data.SoftDelete/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Api.Data.SoftDelete/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Api.Data.SoftDelete/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Data.SoftDelete/.claude/skills/nano-remove-entity/SKILL.md b/Api.Data.SoftDelete/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Api.Data.SoftDelete/.claude/skills/nano-remove-entity/SKILL.md +++ b/Api.Data.SoftDelete/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Data.SoftDelete/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Data.SoftDelete/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Api.Data.SoftDelete/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Data.SoftDelete/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 75ed9535..f8345ca6 100644 --- a/Api.Data.SoftDelete/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.SoftDelete/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Data.SoftDelete/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api.Data.SoftDelete/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Api.Data.SoftDelete/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Api.Data.SoftDelete/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Data.SoftDelete/.github/prompts/nano-remove-entity.prompt.md b/Api.Data.SoftDelete/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Api.Data.SoftDelete/.github/prompts/nano-remove-entity.prompt.md +++ b/Api.Data.SoftDelete/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Data.SoftDelete/AGENTS.md b/Api.Data.SoftDelete/AGENTS.md index 26f63772..3805edf2 100644 --- a/Api.Data.SoftDelete/AGENTS.md +++ b/Api.Data.SoftDelete/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Api.Data.SqLite/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Data.SqLite/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Api.Data.SqLite/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Data.SqLite/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 994e8e3f..d9f45740 100644 --- a/Api.Data.SqLite/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.SqLite/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Data.SqLite/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Api.Data.SqLite/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Api.Data.SqLite/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Api.Data.SqLite/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Data.SqLite/.claude/skills/nano-remove-entity/SKILL.md b/Api.Data.SqLite/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Api.Data.SqLite/.claude/skills/nano-remove-entity/SKILL.md +++ b/Api.Data.SqLite/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Data.SqLite/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Data.SqLite/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Api.Data.SqLite/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Data.SqLite/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 75ed9535..f8345ca6 100644 --- a/Api.Data.SqLite/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.SqLite/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Data.SqLite/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api.Data.SqLite/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Api.Data.SqLite/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Api.Data.SqLite/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Data.SqLite/.github/prompts/nano-remove-entity.prompt.md b/Api.Data.SqLite/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Api.Data.SqLite/.github/prompts/nano-remove-entity.prompt.md +++ b/Api.Data.SqLite/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Data.SqLite/AGENTS.md b/Api.Data.SqLite/AGENTS.md index 26f63772..3805edf2 100644 --- a/Api.Data.SqLite/AGENTS.md +++ b/Api.Data.SqLite/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 994e8e3f..d9f45740 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 @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Data.SqlServer.Spatial/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Api.Data.SqlServer.Spatial/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Api.Data.SqlServer.Spatial/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Api.Data.SqlServer.Spatial/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Data.SqlServer.Spatial/.claude/skills/nano-remove-entity/SKILL.md b/Api.Data.SqlServer.Spatial/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Api.Data.SqlServer.Spatial/.claude/skills/nano-remove-entity/SKILL.md +++ b/Api.Data.SqlServer.Spatial/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 75ed9535..f8345ca6 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 @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Data.SqlServer.Spatial/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api.Data.SqlServer.Spatial/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Api.Data.SqlServer.Spatial/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Api.Data.SqlServer.Spatial/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Data.SqlServer.Spatial/.github/prompts/nano-remove-entity.prompt.md b/Api.Data.SqlServer.Spatial/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Api.Data.SqlServer.Spatial/.github/prompts/nano-remove-entity.prompt.md +++ b/Api.Data.SqlServer.Spatial/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Data.SqlServer.Spatial/AGENTS.md b/Api.Data.SqlServer.Spatial/AGENTS.md index 26f63772..3805edf2 100644 --- a/Api.Data.SqlServer.Spatial/AGENTS.md +++ b/Api.Data.SqlServer.Spatial/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Api.Data.SqlServer/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Data.SqlServer/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Api.Data.SqlServer/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Data.SqlServer/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 994e8e3f..d9f45740 100644 --- a/Api.Data.SqlServer/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.SqlServer/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Data.SqlServer/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Api.Data.SqlServer/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Api.Data.SqlServer/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Api.Data.SqlServer/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Data.SqlServer/.claude/skills/nano-remove-entity/SKILL.md b/Api.Data.SqlServer/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Api.Data.SqlServer/.claude/skills/nano-remove-entity/SKILL.md +++ b/Api.Data.SqlServer/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Data.SqlServer/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Data.SqlServer/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Api.Data.SqlServer/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Data.SqlServer/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 75ed9535..f8345ca6 100644 --- a/Api.Data.SqlServer/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.SqlServer/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Data.SqlServer/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api.Data.SqlServer/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Api.Data.SqlServer/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Api.Data.SqlServer/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Data.SqlServer/.github/prompts/nano-remove-entity.prompt.md b/Api.Data.SqlServer/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Api.Data.SqlServer/.github/prompts/nano-remove-entity.prompt.md +++ b/Api.Data.SqlServer/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Data.SqlServer/AGENTS.md b/Api.Data.SqlServer/AGENTS.md index 26f63772..3805edf2 100644 --- a/Api.Data.SqlServer/AGENTS.md +++ b/Api.Data.SqlServer/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Api.Data.Triggers/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Data.Triggers/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Api.Data.Triggers/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Data.Triggers/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 994e8e3f..d9f45740 100644 --- a/Api.Data.Triggers/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.Triggers/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Data.Triggers/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Api.Data.Triggers/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Api.Data.Triggers/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Api.Data.Triggers/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Data.Triggers/.claude/skills/nano-remove-entity/SKILL.md b/Api.Data.Triggers/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Api.Data.Triggers/.claude/skills/nano-remove-entity/SKILL.md +++ b/Api.Data.Triggers/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Data.Triggers/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Data.Triggers/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Api.Data.Triggers/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Data.Triggers/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 75ed9535..f8345ca6 100644 --- a/Api.Data.Triggers/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.Triggers/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Data.Triggers/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api.Data.Triggers/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Api.Data.Triggers/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Api.Data.Triggers/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Data.Triggers/.github/prompts/nano-remove-entity.prompt.md b/Api.Data.Triggers/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Api.Data.Triggers/.github/prompts/nano-remove-entity.prompt.md +++ b/Api.Data.Triggers/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Data.Triggers/AGENTS.md b/Api.Data.Triggers/AGENTS.md index 26f63772..3805edf2 100644 --- a/Api.Data.Triggers/AGENTS.md +++ b/Api.Data.Triggers/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Api.Documentation.Csp/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Documentation.Csp/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Api.Documentation.Csp/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Documentation.Csp/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 994e8e3f..d9f45740 100644 --- a/Api.Documentation.Csp/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Documentation.Csp/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Documentation.Csp/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Api.Documentation.Csp/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Api.Documentation.Csp/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Api.Documentation.Csp/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Documentation.Csp/.claude/skills/nano-remove-entity/SKILL.md b/Api.Documentation.Csp/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Api.Documentation.Csp/.claude/skills/nano-remove-entity/SKILL.md +++ b/Api.Documentation.Csp/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Documentation.Csp/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Documentation.Csp/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Api.Documentation.Csp/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Documentation.Csp/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 75ed9535..f8345ca6 100644 --- a/Api.Documentation.Csp/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Documentation.Csp/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Documentation.Csp/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api.Documentation.Csp/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Api.Documentation.Csp/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Api.Documentation.Csp/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Documentation.Csp/.github/prompts/nano-remove-entity.prompt.md b/Api.Documentation.Csp/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Api.Documentation.Csp/.github/prompts/nano-remove-entity.prompt.md +++ b/Api.Documentation.Csp/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Documentation.Csp/AGENTS.md b/Api.Documentation.Csp/AGENTS.md index 26f63772..3805edf2 100644 --- a/Api.Documentation.Csp/AGENTS.md +++ b/Api.Documentation.Csp/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Api.Documentation/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Documentation/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Api.Documentation/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Documentation/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 100644 --- a/Api.Documentation/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.Documentation/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller diff --git a/Api.Documentation/.claude/skills/nano-add-entity/SKILL.md b/Api.Documentation/.claude/skills/nano-add-entity/SKILL.md index 994e8e3f..d9f45740 100644 --- a/Api.Documentation/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Documentation/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 100644 --- a/Api.Documentation/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Documentation/.claude/skills/nano-add-postman-collection/SKILL.md @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Documentation/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Api.Documentation/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Api.Documentation/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Api.Documentation/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Documentation/.claude/skills/nano-remove-entity/SKILL.md b/Api.Documentation/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Api.Documentation/.claude/skills/nano-remove-entity/SKILL.md +++ b/Api.Documentation/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Documentation/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Documentation/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Api.Documentation/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Documentation/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 100644 --- a/Api.Documentation/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Documentation/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller diff --git a/Api.Documentation/.github/prompts/nano-add-entity.prompt.md b/Api.Documentation/.github/prompts/nano-add-entity.prompt.md index 75ed9535..f8345ca6 100644 --- a/Api.Documentation/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Documentation/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 100644 --- a/Api.Documentation/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Documentation/.github/prompts/nano-add-postman-collection.prompt.md @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Documentation/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api.Documentation/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Api.Documentation/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Api.Documentation/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Documentation/.github/prompts/nano-remove-entity.prompt.md b/Api.Documentation/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Api.Documentation/.github/prompts/nano-remove-entity.prompt.md +++ b/Api.Documentation/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Documentation/AGENTS.md b/Api.Documentation/AGENTS.md index 26f63772..3805edf2 100644 --- a/Api.Documentation/AGENTS.md +++ b/Api.Documentation/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Api.ErrorHandling/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.ErrorHandling/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Api.ErrorHandling/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.ErrorHandling/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 100644 --- a/Api.ErrorHandling/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.ErrorHandling/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller diff --git a/Api.ErrorHandling/.claude/skills/nano-add-entity/SKILL.md b/Api.ErrorHandling/.claude/skills/nano-add-entity/SKILL.md index 994e8e3f..d9f45740 100644 --- a/Api.ErrorHandling/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.ErrorHandling/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 100644 --- a/Api.ErrorHandling/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.ErrorHandling/.claude/skills/nano-add-postman-collection/SKILL.md @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Api.ErrorHandling/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Api.ErrorHandling/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Api.ErrorHandling/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Api.ErrorHandling/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.ErrorHandling/.claude/skills/nano-remove-entity/SKILL.md b/Api.ErrorHandling/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Api.ErrorHandling/.claude/skills/nano-remove-entity/SKILL.md +++ b/Api.ErrorHandling/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.ErrorHandling/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.ErrorHandling/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Api.ErrorHandling/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.ErrorHandling/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 100644 --- a/Api.ErrorHandling/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.ErrorHandling/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller diff --git a/Api.ErrorHandling/.github/prompts/nano-add-entity.prompt.md b/Api.ErrorHandling/.github/prompts/nano-add-entity.prompt.md index 75ed9535..f8345ca6 100644 --- a/Api.ErrorHandling/.github/prompts/nano-add-entity.prompt.md +++ b/Api.ErrorHandling/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 100644 --- a/Api.ErrorHandling/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.ErrorHandling/.github/prompts/nano-add-postman-collection.prompt.md @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Api.ErrorHandling/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api.ErrorHandling/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Api.ErrorHandling/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Api.ErrorHandling/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.ErrorHandling/.github/prompts/nano-remove-entity.prompt.md b/Api.ErrorHandling/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Api.ErrorHandling/.github/prompts/nano-remove-entity.prompt.md +++ b/Api.ErrorHandling/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.ErrorHandling/AGENTS.md b/Api.ErrorHandling/AGENTS.md index 26f63772..3805edf2 100644 --- a/Api.ErrorHandling/AGENTS.md +++ b/Api.ErrorHandling/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Api.Eventing.RabbitMq/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Eventing.RabbitMq/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Api.Eventing.RabbitMq/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Eventing.RabbitMq/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 994e8e3f..d9f45740 100644 --- a/Api.Eventing.RabbitMq/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Eventing.RabbitMq/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Eventing.RabbitMq/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Api.Eventing.RabbitMq/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Api.Eventing.RabbitMq/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Api.Eventing.RabbitMq/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Eventing.RabbitMq/.claude/skills/nano-remove-entity/SKILL.md b/Api.Eventing.RabbitMq/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Api.Eventing.RabbitMq/.claude/skills/nano-remove-entity/SKILL.md +++ b/Api.Eventing.RabbitMq/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Eventing.RabbitMq/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Eventing.RabbitMq/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Api.Eventing.RabbitMq/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Eventing.RabbitMq/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 75ed9535..f8345ca6 100644 --- a/Api.Eventing.RabbitMq/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Eventing.RabbitMq/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Eventing.RabbitMq/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api.Eventing.RabbitMq/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Api.Eventing.RabbitMq/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Api.Eventing.RabbitMq/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Eventing.RabbitMq/.github/prompts/nano-remove-entity.prompt.md b/Api.Eventing.RabbitMq/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Api.Eventing.RabbitMq/.github/prompts/nano-remove-entity.prompt.md +++ b/Api.Eventing.RabbitMq/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Eventing.RabbitMq/AGENTS.md b/Api.Eventing.RabbitMq/AGENTS.md index 26f63772..3805edf2 100644 --- a/Api.Eventing.RabbitMq/AGENTS.md +++ b/Api.Eventing.RabbitMq/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Api.HealthChecks/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.HealthChecks/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Api.HealthChecks/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.HealthChecks/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 100644 --- a/Api.HealthChecks/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.HealthChecks/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller diff --git a/Api.HealthChecks/.claude/skills/nano-add-entity/SKILL.md b/Api.HealthChecks/.claude/skills/nano-add-entity/SKILL.md index 994e8e3f..d9f45740 100644 --- a/Api.HealthChecks/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.HealthChecks/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 100644 --- a/Api.HealthChecks/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.HealthChecks/.claude/skills/nano-add-postman-collection/SKILL.md @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Api.HealthChecks/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Api.HealthChecks/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Api.HealthChecks/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Api.HealthChecks/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.HealthChecks/.claude/skills/nano-remove-entity/SKILL.md b/Api.HealthChecks/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Api.HealthChecks/.claude/skills/nano-remove-entity/SKILL.md +++ b/Api.HealthChecks/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.HealthChecks/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.HealthChecks/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Api.HealthChecks/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.HealthChecks/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 100644 --- a/Api.HealthChecks/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.HealthChecks/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller diff --git a/Api.HealthChecks/.github/prompts/nano-add-entity.prompt.md b/Api.HealthChecks/.github/prompts/nano-add-entity.prompt.md index 75ed9535..f8345ca6 100644 --- a/Api.HealthChecks/.github/prompts/nano-add-entity.prompt.md +++ b/Api.HealthChecks/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 100644 --- a/Api.HealthChecks/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.HealthChecks/.github/prompts/nano-add-postman-collection.prompt.md @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Api.HealthChecks/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api.HealthChecks/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Api.HealthChecks/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Api.HealthChecks/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.HealthChecks/.github/prompts/nano-remove-entity.prompt.md b/Api.HealthChecks/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Api.HealthChecks/.github/prompts/nano-remove-entity.prompt.md +++ b/Api.HealthChecks/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.HealthChecks/AGENTS.md b/Api.HealthChecks/AGENTS.md index 26f63772..3805edf2 100644 --- a/Api.HealthChecks/AGENTS.md +++ b/Api.HealthChecks/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Api.Hosting.Http/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Hosting.Http/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Api.Hosting.Http/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Hosting.Http/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 994e8e3f..d9f45740 100644 --- a/Api.Hosting.Http/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Hosting.Http/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Hosting.Http/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Api.Hosting.Http/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Api.Hosting.Http/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Api.Hosting.Http/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Hosting.Http/.claude/skills/nano-remove-entity/SKILL.md b/Api.Hosting.Http/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Api.Hosting.Http/.claude/skills/nano-remove-entity/SKILL.md +++ b/Api.Hosting.Http/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Hosting.Http/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Hosting.Http/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Api.Hosting.Http/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Hosting.Http/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 75ed9535..f8345ca6 100644 --- a/Api.Hosting.Http/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Hosting.Http/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Hosting.Http/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api.Hosting.Http/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Api.Hosting.Http/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Api.Hosting.Http/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Hosting.Http/.github/prompts/nano-remove-entity.prompt.md b/Api.Hosting.Http/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Api.Hosting.Http/.github/prompts/nano-remove-entity.prompt.md +++ b/Api.Hosting.Http/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Hosting.Http/AGENTS.md b/Api.Hosting.Http/AGENTS.md index 26f63772..3805edf2 100644 --- a/Api.Hosting.Http/AGENTS.md +++ b/Api.Hosting.Http/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Api.Hosting.Https/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Hosting.Https/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Api.Hosting.Https/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Hosting.Https/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 994e8e3f..d9f45740 100644 --- a/Api.Hosting.Https/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Hosting.Https/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Hosting.Https/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Api.Hosting.Https/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Api.Hosting.Https/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Api.Hosting.Https/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Hosting.Https/.claude/skills/nano-remove-entity/SKILL.md b/Api.Hosting.Https/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Api.Hosting.Https/.claude/skills/nano-remove-entity/SKILL.md +++ b/Api.Hosting.Https/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Hosting.Https/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Hosting.Https/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Api.Hosting.Https/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Hosting.Https/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 75ed9535..f8345ca6 100644 --- a/Api.Hosting.Https/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Hosting.Https/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Hosting.Https/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api.Hosting.Https/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Api.Hosting.Https/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Api.Hosting.Https/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Hosting.Https/.github/prompts/nano-remove-entity.prompt.md b/Api.Hosting.Https/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Api.Hosting.Https/.github/prompts/nano-remove-entity.prompt.md +++ b/Api.Hosting.Https/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Hosting.Https/AGENTS.md b/Api.Hosting.Https/AGENTS.md index 26f63772..3805edf2 100644 --- a/Api.Hosting.Https/AGENTS.md +++ b/Api.Hosting.Https/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Api.Hosting.MultipartLimits/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Hosting.MultipartLimits/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Api.Hosting.MultipartLimits/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Hosting.MultipartLimits/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 994e8e3f..d9f45740 100644 --- a/Api.Hosting.MultipartLimits/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Hosting.MultipartLimits/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Hosting.MultipartLimits/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Api.Hosting.MultipartLimits/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Api.Hosting.MultipartLimits/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Api.Hosting.MultipartLimits/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Hosting.MultipartLimits/.claude/skills/nano-remove-entity/SKILL.md b/Api.Hosting.MultipartLimits/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Api.Hosting.MultipartLimits/.claude/skills/nano-remove-entity/SKILL.md +++ b/Api.Hosting.MultipartLimits/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Hosting.MultipartLimits/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Hosting.MultipartLimits/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Api.Hosting.MultipartLimits/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Hosting.MultipartLimits/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 75ed9535..f8345ca6 100644 --- a/Api.Hosting.MultipartLimits/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Hosting.MultipartLimits/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Hosting.MultipartLimits/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api.Hosting.MultipartLimits/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Api.Hosting.MultipartLimits/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Api.Hosting.MultipartLimits/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Hosting.MultipartLimits/.github/prompts/nano-remove-entity.prompt.md b/Api.Hosting.MultipartLimits/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Api.Hosting.MultipartLimits/.github/prompts/nano-remove-entity.prompt.md +++ b/Api.Hosting.MultipartLimits/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Hosting.MultipartLimits/AGENTS.md b/Api.Hosting.MultipartLimits/AGENTS.md index 26f63772..3805edf2 100644 --- a/Api.Hosting.MultipartLimits/AGENTS.md +++ b/Api.Hosting.MultipartLimits/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Api.Localization/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Localization/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Api.Localization/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Localization/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 100644 --- a/Api.Localization/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.Localization/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller diff --git a/Api.Localization/.claude/skills/nano-add-entity/SKILL.md b/Api.Localization/.claude/skills/nano-add-entity/SKILL.md index 994e8e3f..d9f45740 100644 --- a/Api.Localization/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Localization/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 100644 --- a/Api.Localization/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Localization/.claude/skills/nano-add-postman-collection/SKILL.md @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Localization/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Api.Localization/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Api.Localization/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Api.Localization/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Localization/.claude/skills/nano-remove-entity/SKILL.md b/Api.Localization/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Api.Localization/.claude/skills/nano-remove-entity/SKILL.md +++ b/Api.Localization/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Localization/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Localization/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Api.Localization/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Localization/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 100644 --- a/Api.Localization/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Localization/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller diff --git a/Api.Localization/.github/prompts/nano-add-entity.prompt.md b/Api.Localization/.github/prompts/nano-add-entity.prompt.md index 75ed9535..f8345ca6 100644 --- a/Api.Localization/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Localization/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 100644 --- a/Api.Localization/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Localization/.github/prompts/nano-add-postman-collection.prompt.md @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Localization/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api.Localization/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Api.Localization/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Api.Localization/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Localization/.github/prompts/nano-remove-entity.prompt.md b/Api.Localization/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Api.Localization/.github/prompts/nano-remove-entity.prompt.md +++ b/Api.Localization/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Localization/AGENTS.md b/Api.Localization/AGENTS.md index 26f63772..3805edf2 100644 --- a/Api.Localization/AGENTS.md +++ b/Api.Localization/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Api.Logging.Log4Net/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Logging.Log4Net/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Api.Logging.Log4Net/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Logging.Log4Net/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 994e8e3f..d9f45740 100644 --- a/Api.Logging.Log4Net/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Logging.Log4Net/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Logging.Log4Net/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Api.Logging.Log4Net/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Api.Logging.Log4Net/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Api.Logging.Log4Net/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Logging.Log4Net/.claude/skills/nano-remove-entity/SKILL.md b/Api.Logging.Log4Net/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Api.Logging.Log4Net/.claude/skills/nano-remove-entity/SKILL.md +++ b/Api.Logging.Log4Net/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Logging.Log4Net/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Logging.Log4Net/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Api.Logging.Log4Net/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Logging.Log4Net/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 75ed9535..f8345ca6 100644 --- a/Api.Logging.Log4Net/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Logging.Log4Net/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Logging.Log4Net/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api.Logging.Log4Net/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Api.Logging.Log4Net/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Api.Logging.Log4Net/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Logging.Log4Net/.github/prompts/nano-remove-entity.prompt.md b/Api.Logging.Log4Net/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Api.Logging.Log4Net/.github/prompts/nano-remove-entity.prompt.md +++ b/Api.Logging.Log4Net/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Logging.Log4Net/AGENTS.md b/Api.Logging.Log4Net/AGENTS.md index 26f63772..3805edf2 100644 --- a/Api.Logging.Log4Net/AGENTS.md +++ b/Api.Logging.Log4Net/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Api.Logging.Microsoft/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Logging.Microsoft/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Api.Logging.Microsoft/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Logging.Microsoft/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 994e8e3f..d9f45740 100644 --- a/Api.Logging.Microsoft/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Logging.Microsoft/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Logging.Microsoft/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Api.Logging.Microsoft/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Api.Logging.Microsoft/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Api.Logging.Microsoft/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Logging.Microsoft/.claude/skills/nano-remove-entity/SKILL.md b/Api.Logging.Microsoft/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Api.Logging.Microsoft/.claude/skills/nano-remove-entity/SKILL.md +++ b/Api.Logging.Microsoft/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Logging.Microsoft/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Logging.Microsoft/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Api.Logging.Microsoft/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Logging.Microsoft/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 75ed9535..f8345ca6 100644 --- a/Api.Logging.Microsoft/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Logging.Microsoft/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Logging.Microsoft/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api.Logging.Microsoft/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Api.Logging.Microsoft/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Api.Logging.Microsoft/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Logging.Microsoft/.github/prompts/nano-remove-entity.prompt.md b/Api.Logging.Microsoft/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Api.Logging.Microsoft/.github/prompts/nano-remove-entity.prompt.md +++ b/Api.Logging.Microsoft/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Logging.Microsoft/AGENTS.md b/Api.Logging.Microsoft/AGENTS.md index 26f63772..3805edf2 100644 --- a/Api.Logging.Microsoft/AGENTS.md +++ b/Api.Logging.Microsoft/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Api.Logging.NLog/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Logging.NLog/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Api.Logging.NLog/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Logging.NLog/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 994e8e3f..d9f45740 100644 --- a/Api.Logging.NLog/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Logging.NLog/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Logging.NLog/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Api.Logging.NLog/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Api.Logging.NLog/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Api.Logging.NLog/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Logging.NLog/.claude/skills/nano-remove-entity/SKILL.md b/Api.Logging.NLog/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Api.Logging.NLog/.claude/skills/nano-remove-entity/SKILL.md +++ b/Api.Logging.NLog/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Logging.NLog/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Logging.NLog/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Api.Logging.NLog/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Logging.NLog/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 75ed9535..f8345ca6 100644 --- a/Api.Logging.NLog/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Logging.NLog/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Logging.NLog/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api.Logging.NLog/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Api.Logging.NLog/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Api.Logging.NLog/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Logging.NLog/.github/prompts/nano-remove-entity.prompt.md b/Api.Logging.NLog/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Api.Logging.NLog/.github/prompts/nano-remove-entity.prompt.md +++ b/Api.Logging.NLog/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Logging.NLog/AGENTS.md b/Api.Logging.NLog/AGENTS.md index 26f63772..3805edf2 100644 --- a/Api.Logging.NLog/AGENTS.md +++ b/Api.Logging.NLog/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Api.Logging.Serilog/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Logging.Serilog/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Api.Logging.Serilog/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Logging.Serilog/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 994e8e3f..d9f45740 100644 --- a/Api.Logging.Serilog/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Logging.Serilog/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Logging.Serilog/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Api.Logging.Serilog/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Api.Logging.Serilog/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Api.Logging.Serilog/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Logging.Serilog/.claude/skills/nano-remove-entity/SKILL.md b/Api.Logging.Serilog/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Api.Logging.Serilog/.claude/skills/nano-remove-entity/SKILL.md +++ b/Api.Logging.Serilog/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Logging.Serilog/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Logging.Serilog/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Api.Logging.Serilog/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Logging.Serilog/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 75ed9535..f8345ca6 100644 --- a/Api.Logging.Serilog/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Logging.Serilog/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Logging.Serilog/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api.Logging.Serilog/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Api.Logging.Serilog/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Api.Logging.Serilog/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Logging.Serilog/.github/prompts/nano-remove-entity.prompt.md b/Api.Logging.Serilog/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Api.Logging.Serilog/.github/prompts/nano-remove-entity.prompt.md +++ b/Api.Logging.Serilog/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Logging.Serilog/AGENTS.md b/Api.Logging.Serilog/AGENTS.md index 26f63772..3805edf2 100644 --- a/Api.Logging.Serilog/AGENTS.md +++ b/Api.Logging.Serilog/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Api.Metrics/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Metrics/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Api.Metrics/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Metrics/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 100644 --- a/Api.Metrics/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.Metrics/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller diff --git a/Api.Metrics/.claude/skills/nano-add-entity/SKILL.md b/Api.Metrics/.claude/skills/nano-add-entity/SKILL.md index 994e8e3f..d9f45740 100644 --- a/Api.Metrics/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Metrics/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 100644 --- a/Api.Metrics/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Metrics/.claude/skills/nano-add-postman-collection/SKILL.md @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Metrics/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Api.Metrics/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Api.Metrics/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Api.Metrics/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Metrics/.claude/skills/nano-remove-entity/SKILL.md b/Api.Metrics/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Api.Metrics/.claude/skills/nano-remove-entity/SKILL.md +++ b/Api.Metrics/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Metrics/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Metrics/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Api.Metrics/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Metrics/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 100644 --- a/Api.Metrics/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Metrics/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller diff --git a/Api.Metrics/.github/prompts/nano-add-entity.prompt.md b/Api.Metrics/.github/prompts/nano-add-entity.prompt.md index 75ed9535..f8345ca6 100644 --- a/Api.Metrics/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Metrics/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 100644 --- a/Api.Metrics/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Metrics/.github/prompts/nano-add-postman-collection.prompt.md @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Metrics/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api.Metrics/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Api.Metrics/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Api.Metrics/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Metrics/.github/prompts/nano-remove-entity.prompt.md b/Api.Metrics/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Api.Metrics/.github/prompts/nano-remove-entity.prompt.md +++ b/Api.Metrics/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Metrics/AGENTS.md b/Api.Metrics/AGENTS.md index 26f63772..3805edf2 100644 --- a/Api.Metrics/AGENTS.md +++ b/Api.Metrics/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Api.MultipartJson/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.MultipartJson/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Api.MultipartJson/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.MultipartJson/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 100644 --- a/Api.MultipartJson/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.MultipartJson/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller diff --git a/Api.MultipartJson/.claude/skills/nano-add-entity/SKILL.md b/Api.MultipartJson/.claude/skills/nano-add-entity/SKILL.md index 994e8e3f..d9f45740 100644 --- a/Api.MultipartJson/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.MultipartJson/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 100644 --- a/Api.MultipartJson/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.MultipartJson/.claude/skills/nano-add-postman-collection/SKILL.md @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Api.MultipartJson/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Api.MultipartJson/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Api.MultipartJson/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Api.MultipartJson/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.MultipartJson/.claude/skills/nano-remove-entity/SKILL.md b/Api.MultipartJson/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Api.MultipartJson/.claude/skills/nano-remove-entity/SKILL.md +++ b/Api.MultipartJson/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.MultipartJson/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.MultipartJson/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Api.MultipartJson/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.MultipartJson/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 100644 --- a/Api.MultipartJson/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.MultipartJson/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller diff --git a/Api.MultipartJson/.github/prompts/nano-add-entity.prompt.md b/Api.MultipartJson/.github/prompts/nano-add-entity.prompt.md index 75ed9535..f8345ca6 100644 --- a/Api.MultipartJson/.github/prompts/nano-add-entity.prompt.md +++ b/Api.MultipartJson/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 100644 --- a/Api.MultipartJson/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.MultipartJson/.github/prompts/nano-add-postman-collection.prompt.md @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Api.MultipartJson/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api.MultipartJson/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Api.MultipartJson/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Api.MultipartJson/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.MultipartJson/.github/prompts/nano-remove-entity.prompt.md b/Api.MultipartJson/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Api.MultipartJson/.github/prompts/nano-remove-entity.prompt.md +++ b/Api.MultipartJson/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.MultipartJson/AGENTS.md b/Api.MultipartJson/AGENTS.md index 26f63772..3805edf2 100644 --- a/Api.MultipartJson/AGENTS.md +++ b/Api.MultipartJson/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 994e8e3f..d9f45740 100644 --- a/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-remove-entity/SKILL.md b/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-remove-entity/SKILL.md +++ b/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 75ed9535..f8345ca6 100644 --- a/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-entity.prompt.md +++ b/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-remove-entity.prompt.md b/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-remove-entity.prompt.md +++ b/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.PolicyHeaders.ContentSecurityPolicy/AGENTS.md b/Api.PolicyHeaders.ContentSecurityPolicy/AGENTS.md index 26f63772..3805edf2 100644 --- a/Api.PolicyHeaders.ContentSecurityPolicy/AGENTS.md +++ b/Api.PolicyHeaders.ContentSecurityPolicy/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 994e8e3f..d9f45740 100644 --- a/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-remove-entity/SKILL.md b/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-remove-entity/SKILL.md +++ b/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 75ed9535..f8345ca6 100644 --- a/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-entity.prompt.md +++ b/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-remove-entity.prompt.md b/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-remove-entity.prompt.md +++ b/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.PolicyHeaders.ContentTypeOptions/AGENTS.md b/Api.PolicyHeaders.ContentTypeOptions/AGENTS.md index 26f63772..3805edf2 100644 --- a/Api.PolicyHeaders.ContentTypeOptions/AGENTS.md +++ b/Api.PolicyHeaders.ContentTypeOptions/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Api.PolicyHeaders.Cors/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.PolicyHeaders.Cors/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Api.PolicyHeaders.Cors/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.PolicyHeaders.Cors/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 994e8e3f..d9f45740 100644 --- a/Api.PolicyHeaders.Cors/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.PolicyHeaders.Cors/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Api.PolicyHeaders.Cors/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Api.PolicyHeaders.Cors/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Api.PolicyHeaders.Cors/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Api.PolicyHeaders.Cors/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.PolicyHeaders.Cors/.claude/skills/nano-remove-entity/SKILL.md b/Api.PolicyHeaders.Cors/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Api.PolicyHeaders.Cors/.claude/skills/nano-remove-entity/SKILL.md +++ b/Api.PolicyHeaders.Cors/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.PolicyHeaders.Cors/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.PolicyHeaders.Cors/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Api.PolicyHeaders.Cors/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.PolicyHeaders.Cors/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 75ed9535..f8345ca6 100644 --- a/Api.PolicyHeaders.Cors/.github/prompts/nano-add-entity.prompt.md +++ b/Api.PolicyHeaders.Cors/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Api.PolicyHeaders.Cors/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api.PolicyHeaders.Cors/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Api.PolicyHeaders.Cors/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Api.PolicyHeaders.Cors/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.PolicyHeaders.Cors/.github/prompts/nano-remove-entity.prompt.md b/Api.PolicyHeaders.Cors/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Api.PolicyHeaders.Cors/.github/prompts/nano-remove-entity.prompt.md +++ b/Api.PolicyHeaders.Cors/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.PolicyHeaders.Cors/AGENTS.md b/Api.PolicyHeaders.Cors/AGENTS.md index 26f63772..3805edf2 100644 --- a/Api.PolicyHeaders.Cors/AGENTS.md +++ b/Api.PolicyHeaders.Cors/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 994e8e3f..d9f45740 100644 --- a/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-remove-entity/SKILL.md b/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-remove-entity/SKILL.md +++ b/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 75ed9535..f8345ca6 100644 --- a/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-entity.prompt.md +++ b/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-remove-entity.prompt.md b/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-remove-entity.prompt.md +++ b/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.PolicyHeaders.ForwardedHeaders/AGENTS.md b/Api.PolicyHeaders.ForwardedHeaders/AGENTS.md index 26f63772..3805edf2 100644 --- a/Api.PolicyHeaders.ForwardedHeaders/AGENTS.md +++ b/Api.PolicyHeaders.ForwardedHeaders/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 994e8e3f..d9f45740 100644 --- a/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-remove-entity/SKILL.md b/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-remove-entity/SKILL.md +++ b/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 75ed9535..f8345ca6 100644 --- a/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-entity.prompt.md +++ b/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-remove-entity.prompt.md b/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-remove-entity.prompt.md +++ b/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.PolicyHeaders.FrameOptions/AGENTS.md b/Api.PolicyHeaders.FrameOptions/AGENTS.md index 26f63772..3805edf2 100644 --- a/Api.PolicyHeaders.FrameOptions/AGENTS.md +++ b/Api.PolicyHeaders.FrameOptions/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 994e8e3f..d9f45740 100644 --- a/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Api.PolicyHeaders.Hsts/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Api.PolicyHeaders.Hsts/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Api.PolicyHeaders.Hsts/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Api.PolicyHeaders.Hsts/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.PolicyHeaders.Hsts/.claude/skills/nano-remove-entity/SKILL.md b/Api.PolicyHeaders.Hsts/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Api.PolicyHeaders.Hsts/.claude/skills/nano-remove-entity/SKILL.md +++ b/Api.PolicyHeaders.Hsts/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 75ed9535..f8345ca6 100644 --- a/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-entity.prompt.md +++ b/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Api.PolicyHeaders.Hsts/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api.PolicyHeaders.Hsts/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Api.PolicyHeaders.Hsts/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Api.PolicyHeaders.Hsts/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.PolicyHeaders.Hsts/.github/prompts/nano-remove-entity.prompt.md b/Api.PolicyHeaders.Hsts/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Api.PolicyHeaders.Hsts/.github/prompts/nano-remove-entity.prompt.md +++ b/Api.PolicyHeaders.Hsts/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.PolicyHeaders.Hsts/AGENTS.md b/Api.PolicyHeaders.Hsts/AGENTS.md index 26f63772..3805edf2 100644 --- a/Api.PolicyHeaders.Hsts/AGENTS.md +++ b/Api.PolicyHeaders.Hsts/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 994e8e3f..d9f45740 100644 --- a/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-remove-entity/SKILL.md b/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-remove-entity/SKILL.md +++ b/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 75ed9535..f8345ca6 100644 --- a/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-entity.prompt.md +++ b/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-remove-entity.prompt.md b/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-remove-entity.prompt.md +++ b/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.PolicyHeaders.ReferrerPolicy/AGENTS.md b/Api.PolicyHeaders.ReferrerPolicy/AGENTS.md index 26f63772..3805edf2 100644 --- a/Api.PolicyHeaders.ReferrerPolicy/AGENTS.md +++ b/Api.PolicyHeaders.ReferrerPolicy/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Api.PolicyHeaders.Robots/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.PolicyHeaders.Robots/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Api.PolicyHeaders.Robots/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.PolicyHeaders.Robots/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 994e8e3f..d9f45740 100644 --- a/Api.PolicyHeaders.Robots/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.PolicyHeaders.Robots/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Api.PolicyHeaders.Robots/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Api.PolicyHeaders.Robots/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Api.PolicyHeaders.Robots/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Api.PolicyHeaders.Robots/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.PolicyHeaders.Robots/.claude/skills/nano-remove-entity/SKILL.md b/Api.PolicyHeaders.Robots/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Api.PolicyHeaders.Robots/.claude/skills/nano-remove-entity/SKILL.md +++ b/Api.PolicyHeaders.Robots/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.PolicyHeaders.Robots/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.PolicyHeaders.Robots/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Api.PolicyHeaders.Robots/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.PolicyHeaders.Robots/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 75ed9535..f8345ca6 100644 --- a/Api.PolicyHeaders.Robots/.github/prompts/nano-add-entity.prompt.md +++ b/Api.PolicyHeaders.Robots/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Api.PolicyHeaders.Robots/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api.PolicyHeaders.Robots/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Api.PolicyHeaders.Robots/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Api.PolicyHeaders.Robots/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.PolicyHeaders.Robots/.github/prompts/nano-remove-entity.prompt.md b/Api.PolicyHeaders.Robots/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Api.PolicyHeaders.Robots/.github/prompts/nano-remove-entity.prompt.md +++ b/Api.PolicyHeaders.Robots/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.PolicyHeaders.Robots/AGENTS.md b/Api.PolicyHeaders.Robots/AGENTS.md index 26f63772..3805edf2 100644 --- a/Api.PolicyHeaders.Robots/AGENTS.md +++ b/Api.PolicyHeaders.Robots/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 994e8e3f..d9f45740 100644 --- a/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Api.PolicyHeaders.XssProtection/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Api.PolicyHeaders.XssProtection/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Api.PolicyHeaders.XssProtection/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Api.PolicyHeaders.XssProtection/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.PolicyHeaders.XssProtection/.claude/skills/nano-remove-entity/SKILL.md b/Api.PolicyHeaders.XssProtection/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Api.PolicyHeaders.XssProtection/.claude/skills/nano-remove-entity/SKILL.md +++ b/Api.PolicyHeaders.XssProtection/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 75ed9535..f8345ca6 100644 --- a/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-entity.prompt.md +++ b/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Api.PolicyHeaders.XssProtection/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api.PolicyHeaders.XssProtection/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Api.PolicyHeaders.XssProtection/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Api.PolicyHeaders.XssProtection/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.PolicyHeaders.XssProtection/.github/prompts/nano-remove-entity.prompt.md b/Api.PolicyHeaders.XssProtection/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Api.PolicyHeaders.XssProtection/.github/prompts/nano-remove-entity.prompt.md +++ b/Api.PolicyHeaders.XssProtection/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.PolicyHeaders.XssProtection/AGENTS.md b/Api.PolicyHeaders.XssProtection/AGENTS.md index 26f63772..3805edf2 100644 --- a/Api.PolicyHeaders.XssProtection/AGENTS.md +++ b/Api.PolicyHeaders.XssProtection/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Api.RequestTracing/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.RequestTracing/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Api.RequestTracing/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.RequestTracing/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 100644 --- a/Api.RequestTracing/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.RequestTracing/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller diff --git a/Api.RequestTracing/.claude/skills/nano-add-entity/SKILL.md b/Api.RequestTracing/.claude/skills/nano-add-entity/SKILL.md index 994e8e3f..d9f45740 100644 --- a/Api.RequestTracing/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.RequestTracing/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 100644 --- a/Api.RequestTracing/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.RequestTracing/.claude/skills/nano-add-postman-collection/SKILL.md @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Api.RequestTracing/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Api.RequestTracing/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Api.RequestTracing/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Api.RequestTracing/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.RequestTracing/.claude/skills/nano-remove-entity/SKILL.md b/Api.RequestTracing/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Api.RequestTracing/.claude/skills/nano-remove-entity/SKILL.md +++ b/Api.RequestTracing/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.RequestTracing/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.RequestTracing/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Api.RequestTracing/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.RequestTracing/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 100644 --- a/Api.RequestTracing/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.RequestTracing/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller diff --git a/Api.RequestTracing/.github/prompts/nano-add-entity.prompt.md b/Api.RequestTracing/.github/prompts/nano-add-entity.prompt.md index 75ed9535..f8345ca6 100644 --- a/Api.RequestTracing/.github/prompts/nano-add-entity.prompt.md +++ b/Api.RequestTracing/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 100644 --- a/Api.RequestTracing/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.RequestTracing/.github/prompts/nano-add-postman-collection.prompt.md @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Api.RequestTracing/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api.RequestTracing/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Api.RequestTracing/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Api.RequestTracing/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.RequestTracing/.github/prompts/nano-remove-entity.prompt.md b/Api.RequestTracing/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Api.RequestTracing/.github/prompts/nano-remove-entity.prompt.md +++ b/Api.RequestTracing/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.RequestTracing/AGENTS.md b/Api.RequestTracing/AGENTS.md index 26f63772..3805edf2 100644 --- a/Api.RequestTracing/AGENTS.md +++ b/Api.RequestTracing/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Api.ResponseCache/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.ResponseCache/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Api.ResponseCache/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.ResponseCache/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 100644 --- a/Api.ResponseCache/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.ResponseCache/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller diff --git a/Api.ResponseCache/.claude/skills/nano-add-entity/SKILL.md b/Api.ResponseCache/.claude/skills/nano-add-entity/SKILL.md index 994e8e3f..d9f45740 100644 --- a/Api.ResponseCache/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.ResponseCache/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 100644 --- a/Api.ResponseCache/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.ResponseCache/.claude/skills/nano-add-postman-collection/SKILL.md @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Api.ResponseCache/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Api.ResponseCache/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Api.ResponseCache/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Api.ResponseCache/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.ResponseCache/.claude/skills/nano-remove-entity/SKILL.md b/Api.ResponseCache/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Api.ResponseCache/.claude/skills/nano-remove-entity/SKILL.md +++ b/Api.ResponseCache/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.ResponseCache/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.ResponseCache/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Api.ResponseCache/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.ResponseCache/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 100644 --- a/Api.ResponseCache/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.ResponseCache/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller diff --git a/Api.ResponseCache/.github/prompts/nano-add-entity.prompt.md b/Api.ResponseCache/.github/prompts/nano-add-entity.prompt.md index 75ed9535..f8345ca6 100644 --- a/Api.ResponseCache/.github/prompts/nano-add-entity.prompt.md +++ b/Api.ResponseCache/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 100644 --- a/Api.ResponseCache/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.ResponseCache/.github/prompts/nano-add-postman-collection.prompt.md @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Api.ResponseCache/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api.ResponseCache/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Api.ResponseCache/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Api.ResponseCache/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.ResponseCache/.github/prompts/nano-remove-entity.prompt.md b/Api.ResponseCache/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Api.ResponseCache/.github/prompts/nano-remove-entity.prompt.md +++ b/Api.ResponseCache/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.ResponseCache/AGENTS.md b/Api.ResponseCache/AGENTS.md index 26f63772..3805edf2 100644 --- a/Api.ResponseCache/AGENTS.md +++ b/Api.ResponseCache/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Api.ResponseCompression/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.ResponseCompression/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Api.ResponseCompression/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.ResponseCompression/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 100644 --- a/Api.ResponseCompression/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.ResponseCompression/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller diff --git a/Api.ResponseCompression/.claude/skills/nano-add-entity/SKILL.md b/Api.ResponseCompression/.claude/skills/nano-add-entity/SKILL.md index 994e8e3f..d9f45740 100644 --- a/Api.ResponseCompression/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.ResponseCompression/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 100644 --- a/Api.ResponseCompression/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.ResponseCompression/.claude/skills/nano-add-postman-collection/SKILL.md @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Api.ResponseCompression/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Api.ResponseCompression/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Api.ResponseCompression/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Api.ResponseCompression/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.ResponseCompression/.claude/skills/nano-remove-entity/SKILL.md b/Api.ResponseCompression/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Api.ResponseCompression/.claude/skills/nano-remove-entity/SKILL.md +++ b/Api.ResponseCompression/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.ResponseCompression/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.ResponseCompression/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Api.ResponseCompression/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.ResponseCompression/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 100644 --- a/Api.ResponseCompression/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.ResponseCompression/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller diff --git a/Api.ResponseCompression/.github/prompts/nano-add-entity.prompt.md b/Api.ResponseCompression/.github/prompts/nano-add-entity.prompt.md index 75ed9535..f8345ca6 100644 --- a/Api.ResponseCompression/.github/prompts/nano-add-entity.prompt.md +++ b/Api.ResponseCompression/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 100644 --- a/Api.ResponseCompression/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.ResponseCompression/.github/prompts/nano-add-postman-collection.prompt.md @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Api.ResponseCompression/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api.ResponseCompression/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Api.ResponseCompression/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Api.ResponseCompression/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.ResponseCompression/.github/prompts/nano-remove-entity.prompt.md b/Api.ResponseCompression/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Api.ResponseCompression/.github/prompts/nano-remove-entity.prompt.md +++ b/Api.ResponseCompression/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.ResponseCompression/AGENTS.md b/Api.ResponseCompression/AGENTS.md index 26f63772..3805edf2 100644 --- a/Api.ResponseCompression/AGENTS.md +++ b/Api.ResponseCompression/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Api.Session/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Session/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Api.Session/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Session/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 100644 --- a/Api.Session/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.Session/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller diff --git a/Api.Session/.claude/skills/nano-add-entity/SKILL.md b/Api.Session/.claude/skills/nano-add-entity/SKILL.md index 994e8e3f..d9f45740 100644 --- a/Api.Session/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Session/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 100644 --- a/Api.Session/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Session/.claude/skills/nano-add-postman-collection/SKILL.md @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Session/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Api.Session/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Api.Session/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Api.Session/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Session/.claude/skills/nano-remove-entity/SKILL.md b/Api.Session/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Api.Session/.claude/skills/nano-remove-entity/SKILL.md +++ b/Api.Session/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Session/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Session/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Api.Session/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Session/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 100644 --- a/Api.Session/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Session/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller diff --git a/Api.Session/.github/prompts/nano-add-entity.prompt.md b/Api.Session/.github/prompts/nano-add-entity.prompt.md index 75ed9535..f8345ca6 100644 --- a/Api.Session/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Session/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 100644 --- a/Api.Session/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Session/.github/prompts/nano-add-postman-collection.prompt.md @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Session/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api.Session/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Api.Session/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Api.Session/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Session/.github/prompts/nano-remove-entity.prompt.md b/Api.Session/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Api.Session/.github/prompts/nano-remove-entity.prompt.md +++ b/Api.Session/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Session/AGENTS.md b/Api.Session/AGENTS.md index 26f63772..3805edf2 100644 --- a/Api.Session/AGENTS.md +++ b/Api.Session/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Api.StartupTasks/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.StartupTasks/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Api.StartupTasks/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.StartupTasks/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 100644 --- a/Api.StartupTasks/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.StartupTasks/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller diff --git a/Api.StartupTasks/.claude/skills/nano-add-entity/SKILL.md b/Api.StartupTasks/.claude/skills/nano-add-entity/SKILL.md index 994e8e3f..d9f45740 100644 --- a/Api.StartupTasks/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.StartupTasks/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 100644 --- a/Api.StartupTasks/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.StartupTasks/.claude/skills/nano-add-postman-collection/SKILL.md @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Api.StartupTasks/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Api.StartupTasks/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Api.StartupTasks/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Api.StartupTasks/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.StartupTasks/.claude/skills/nano-remove-entity/SKILL.md b/Api.StartupTasks/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Api.StartupTasks/.claude/skills/nano-remove-entity/SKILL.md +++ b/Api.StartupTasks/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.StartupTasks/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.StartupTasks/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Api.StartupTasks/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.StartupTasks/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 100644 --- a/Api.StartupTasks/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.StartupTasks/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller diff --git a/Api.StartupTasks/.github/prompts/nano-add-entity.prompt.md b/Api.StartupTasks/.github/prompts/nano-add-entity.prompt.md index 75ed9535..f8345ca6 100644 --- a/Api.StartupTasks/.github/prompts/nano-add-entity.prompt.md +++ b/Api.StartupTasks/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 100644 --- a/Api.StartupTasks/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.StartupTasks/.github/prompts/nano-add-postman-collection.prompt.md @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Api.StartupTasks/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api.StartupTasks/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Api.StartupTasks/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Api.StartupTasks/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.StartupTasks/.github/prompts/nano-remove-entity.prompt.md b/Api.StartupTasks/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Api.StartupTasks/.github/prompts/nano-remove-entity.prompt.md +++ b/Api.StartupTasks/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.StartupTasks/AGENTS.md b/Api.StartupTasks/AGENTS.md index 26f63772..3805edf2 100644 --- a/Api.StartupTasks/AGENTS.md +++ b/Api.StartupTasks/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Api.StaticFiles/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.StaticFiles/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Api.StaticFiles/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.StaticFiles/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 100644 --- a/Api.StaticFiles/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.StaticFiles/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller diff --git a/Api.StaticFiles/.claude/skills/nano-add-entity/SKILL.md b/Api.StaticFiles/.claude/skills/nano-add-entity/SKILL.md index 994e8e3f..d9f45740 100644 --- a/Api.StaticFiles/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.StaticFiles/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 100644 --- a/Api.StaticFiles/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.StaticFiles/.claude/skills/nano-add-postman-collection/SKILL.md @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Api.StaticFiles/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Api.StaticFiles/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Api.StaticFiles/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Api.StaticFiles/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.StaticFiles/.claude/skills/nano-remove-entity/SKILL.md b/Api.StaticFiles/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Api.StaticFiles/.claude/skills/nano-remove-entity/SKILL.md +++ b/Api.StaticFiles/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.StaticFiles/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.StaticFiles/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Api.StaticFiles/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.StaticFiles/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 100644 --- a/Api.StaticFiles/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.StaticFiles/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller diff --git a/Api.StaticFiles/.github/prompts/nano-add-entity.prompt.md b/Api.StaticFiles/.github/prompts/nano-add-entity.prompt.md index 75ed9535..f8345ca6 100644 --- a/Api.StaticFiles/.github/prompts/nano-add-entity.prompt.md +++ b/Api.StaticFiles/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 100644 --- a/Api.StaticFiles/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.StaticFiles/.github/prompts/nano-add-postman-collection.prompt.md @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Api.StaticFiles/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api.StaticFiles/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Api.StaticFiles/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Api.StaticFiles/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.StaticFiles/.github/prompts/nano-remove-entity.prompt.md b/Api.StaticFiles/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Api.StaticFiles/.github/prompts/nano-remove-entity.prompt.md +++ b/Api.StaticFiles/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.StaticFiles/AGENTS.md b/Api.StaticFiles/AGENTS.md index 26f63772..3805edf2 100644 --- a/Api.StaticFiles/AGENTS.md +++ b/Api.StaticFiles/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Api.Storage.Azure/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Storage.Azure/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Api.Storage.Azure/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Storage.Azure/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 994e8e3f..d9f45740 100644 --- a/Api.Storage.Azure/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Storage.Azure/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Storage.Azure/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Api.Storage.Azure/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Api.Storage.Azure/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Api.Storage.Azure/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Storage.Azure/.claude/skills/nano-remove-entity/SKILL.md b/Api.Storage.Azure/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Api.Storage.Azure/.claude/skills/nano-remove-entity/SKILL.md +++ b/Api.Storage.Azure/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Storage.Azure/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Storage.Azure/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Api.Storage.Azure/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Storage.Azure/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 75ed9535..f8345ca6 100644 --- a/Api.Storage.Azure/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Storage.Azure/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Storage.Azure/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api.Storage.Azure/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Api.Storage.Azure/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Api.Storage.Azure/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Storage.Azure/.github/prompts/nano-remove-entity.prompt.md b/Api.Storage.Azure/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Api.Storage.Azure/.github/prompts/nano-remove-entity.prompt.md +++ b/Api.Storage.Azure/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Storage.Azure/AGENTS.md b/Api.Storage.Azure/AGENTS.md index 26f63772..3805edf2 100644 --- a/Api.Storage.Azure/AGENTS.md +++ b/Api.Storage.Azure/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Api.Storage.Local/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Storage.Local/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Api.Storage.Local/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Storage.Local/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 994e8e3f..d9f45740 100644 --- a/Api.Storage.Local/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Storage.Local/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Storage.Local/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Api.Storage.Local/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Api.Storage.Local/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Api.Storage.Local/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Storage.Local/.claude/skills/nano-remove-entity/SKILL.md b/Api.Storage.Local/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Api.Storage.Local/.claude/skills/nano-remove-entity/SKILL.md +++ b/Api.Storage.Local/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Storage.Local/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Storage.Local/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Api.Storage.Local/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Storage.Local/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 75ed9535..f8345ca6 100644 --- a/Api.Storage.Local/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Storage.Local/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Storage.Local/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api.Storage.Local/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Api.Storage.Local/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Api.Storage.Local/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Storage.Local/.github/prompts/nano-remove-entity.prompt.md b/Api.Storage.Local/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Api.Storage.Local/.github/prompts/nano-remove-entity.prompt.md +++ b/Api.Storage.Local/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Storage.Local/AGENTS.md b/Api.Storage.Local/AGENTS.md index 26f63772..3805edf2 100644 --- a/Api.Storage.Local/AGENTS.md +++ b/Api.Storage.Local/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Api.TimeZone/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.TimeZone/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Api.TimeZone/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.TimeZone/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 100644 --- a/Api.TimeZone/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.TimeZone/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller diff --git a/Api.TimeZone/.claude/skills/nano-add-entity/SKILL.md b/Api.TimeZone/.claude/skills/nano-add-entity/SKILL.md index 994e8e3f..d9f45740 100644 --- a/Api.TimeZone/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.TimeZone/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 100644 --- a/Api.TimeZone/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.TimeZone/.claude/skills/nano-add-postman-collection/SKILL.md @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Api.TimeZone/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Api.TimeZone/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Api.TimeZone/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Api.TimeZone/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.TimeZone/.claude/skills/nano-remove-entity/SKILL.md b/Api.TimeZone/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Api.TimeZone/.claude/skills/nano-remove-entity/SKILL.md +++ b/Api.TimeZone/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.TimeZone/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.TimeZone/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Api.TimeZone/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.TimeZone/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 100644 --- a/Api.TimeZone/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.TimeZone/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller diff --git a/Api.TimeZone/.github/prompts/nano-add-entity.prompt.md b/Api.TimeZone/.github/prompts/nano-add-entity.prompt.md index 75ed9535..f8345ca6 100644 --- a/Api.TimeZone/.github/prompts/nano-add-entity.prompt.md +++ b/Api.TimeZone/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 100644 --- a/Api.TimeZone/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.TimeZone/.github/prompts/nano-add-postman-collection.prompt.md @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Api.TimeZone/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api.TimeZone/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Api.TimeZone/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Api.TimeZone/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.TimeZone/.github/prompts/nano-remove-entity.prompt.md b/Api.TimeZone/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Api.TimeZone/.github/prompts/nano-remove-entity.prompt.md +++ b/Api.TimeZone/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.TimeZone/AGENTS.md b/Api.TimeZone/AGENTS.md index 26f63772..3805edf2 100644 --- a/Api.TimeZone/AGENTS.md +++ b/Api.TimeZone/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Api.Versioning/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.Versioning/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Api.Versioning/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.Versioning/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 100644 --- a/Api.Versioning/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.Versioning/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller diff --git a/Api.Versioning/.claude/skills/nano-add-entity/SKILL.md b/Api.Versioning/.claude/skills/nano-add-entity/SKILL.md index 994e8e3f..d9f45740 100644 --- a/Api.Versioning/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Versioning/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 100644 --- a/Api.Versioning/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Versioning/.claude/skills/nano-add-postman-collection/SKILL.md @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Versioning/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Api.Versioning/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Api.Versioning/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Api.Versioning/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Versioning/.claude/skills/nano-remove-entity/SKILL.md b/Api.Versioning/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Api.Versioning/.claude/skills/nano-remove-entity/SKILL.md +++ b/Api.Versioning/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Versioning/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.Versioning/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Api.Versioning/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.Versioning/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 100644 --- a/Api.Versioning/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Versioning/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller diff --git a/Api.Versioning/.github/prompts/nano-add-entity.prompt.md b/Api.Versioning/.github/prompts/nano-add-entity.prompt.md index 75ed9535..f8345ca6 100644 --- a/Api.Versioning/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Versioning/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 100644 --- a/Api.Versioning/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Versioning/.github/prompts/nano-add-postman-collection.prompt.md @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Api.Versioning/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api.Versioning/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Api.Versioning/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Api.Versioning/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.Versioning/.github/prompts/nano-remove-entity.prompt.md b/Api.Versioning/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Api.Versioning/.github/prompts/nano-remove-entity.prompt.md +++ b/Api.Versioning/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.Versioning/AGENTS.md b/Api.Versioning/AGENTS.md index 26f63772..3805edf2 100644 --- a/Api.Versioning/AGENTS.md +++ b/Api.Versioning/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Api.VirusScan/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api.VirusScan/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Api.VirusScan/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api.VirusScan/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 100644 --- a/Api.VirusScan/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.VirusScan/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller diff --git a/Api.VirusScan/.claude/skills/nano-add-entity/SKILL.md b/Api.VirusScan/.claude/skills/nano-add-entity/SKILL.md index 994e8e3f..d9f45740 100644 --- a/Api.VirusScan/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.VirusScan/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 100644 --- a/Api.VirusScan/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.VirusScan/.claude/skills/nano-add-postman-collection/SKILL.md @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Api.VirusScan/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Api.VirusScan/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Api.VirusScan/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Api.VirusScan/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.VirusScan/.claude/skills/nano-remove-entity/SKILL.md b/Api.VirusScan/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Api.VirusScan/.claude/skills/nano-remove-entity/SKILL.md +++ b/Api.VirusScan/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.VirusScan/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api.VirusScan/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Api.VirusScan/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api.VirusScan/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 100644 --- a/Api.VirusScan/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.VirusScan/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller diff --git a/Api.VirusScan/.github/prompts/nano-add-entity.prompt.md b/Api.VirusScan/.github/prompts/nano-add-entity.prompt.md index 75ed9535..f8345ca6 100644 --- a/Api.VirusScan/.github/prompts/nano-add-entity.prompt.md +++ b/Api.VirusScan/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 100644 --- a/Api.VirusScan/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.VirusScan/.github/prompts/nano-add-postman-collection.prompt.md @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Api.VirusScan/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api.VirusScan/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Api.VirusScan/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Api.VirusScan/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api.VirusScan/.github/prompts/nano-remove-entity.prompt.md b/Api.VirusScan/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Api.VirusScan/.github/prompts/nano-remove-entity.prompt.md +++ b/Api.VirusScan/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api.VirusScan/AGENTS.md b/Api.VirusScan/AGENTS.md index 26f63772..3805edf2 100644 --- a/Api.VirusScan/AGENTS.md +++ b/Api.VirusScan/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Api._Blank/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Api._Blank/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Api._Blank/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Api._Blank/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 100644 --- a/Api._Blank/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api._Blank/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller diff --git a/Api._Blank/.claude/skills/nano-add-entity/SKILL.md b/Api._Blank/.claude/skills/nano-add-entity/SKILL.md index 994e8e3f..d9f45740 100644 --- a/Api._Blank/.claude/skills/nano-add-entity/SKILL.md +++ b/Api._Blank/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 100644 --- a/Api._Blank/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api._Blank/.claude/skills/nano-add-postman-collection/SKILL.md @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Api._Blank/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Api._Blank/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Api._Blank/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Api._Blank/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api._Blank/.claude/skills/nano-remove-entity/SKILL.md b/Api._Blank/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Api._Blank/.claude/skills/nano-remove-entity/SKILL.md +++ b/Api._Blank/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api._Blank/.github/prompts/nano-add-api-client-configuration.prompt.md b/Api._Blank/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Api._Blank/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Api._Blank/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 100644 --- a/Api._Blank/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api._Blank/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller diff --git a/Api._Blank/.github/prompts/nano-add-entity.prompt.md b/Api._Blank/.github/prompts/nano-add-entity.prompt.md index 75ed9535..f8345ca6 100644 --- a/Api._Blank/.github/prompts/nano-add-entity.prompt.md +++ b/Api._Blank/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 100644 --- a/Api._Blank/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api._Blank/.github/prompts/nano-add-postman-collection.prompt.md @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Api._Blank/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api._Blank/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Api._Blank/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Api._Blank/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Api._Blank/.github/prompts/nano-remove-entity.prompt.md b/Api._Blank/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Api._Blank/.github/prompts/nano-remove-entity.prompt.md +++ b/Api._Blank/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Api._Blank/AGENTS.md b/Api._Blank/AGENTS.md index 26f63772..3805edf2 100644 --- a/Api._Blank/AGENTS.md +++ b/Api._Blank/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Console.CustomConfigSection/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Console.CustomConfigSection/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Console.CustomConfigSection/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Console.CustomConfigSection/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 100644 --- a/Console.CustomConfigSection/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Console.CustomConfigSection/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller diff --git a/Console.CustomConfigSection/.claude/skills/nano-add-entity/SKILL.md b/Console.CustomConfigSection/.claude/skills/nano-add-entity/SKILL.md index 994e8e3f..d9f45740 100644 --- a/Console.CustomConfigSection/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.CustomConfigSection/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 100644 --- a/Console.CustomConfigSection/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Console.CustomConfigSection/.claude/skills/nano-add-postman-collection/SKILL.md @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Console.CustomConfigSection/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Console.CustomConfigSection/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Console.CustomConfigSection/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Console.CustomConfigSection/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Console.CustomConfigSection/.claude/skills/nano-remove-entity/SKILL.md b/Console.CustomConfigSection/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Console.CustomConfigSection/.claude/skills/nano-remove-entity/SKILL.md +++ b/Console.CustomConfigSection/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Console.CustomConfigSection/.github/prompts/nano-add-api-client-configuration.prompt.md b/Console.CustomConfigSection/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Console.CustomConfigSection/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Console.CustomConfigSection/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 100644 --- a/Console.CustomConfigSection/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Console.CustomConfigSection/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller diff --git a/Console.CustomConfigSection/.github/prompts/nano-add-entity.prompt.md b/Console.CustomConfigSection/.github/prompts/nano-add-entity.prompt.md index 75ed9535..f8345ca6 100644 --- a/Console.CustomConfigSection/.github/prompts/nano-add-entity.prompt.md +++ b/Console.CustomConfigSection/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 100644 --- a/Console.CustomConfigSection/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Console.CustomConfigSection/.github/prompts/nano-add-postman-collection.prompt.md @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Console.CustomConfigSection/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Console.CustomConfigSection/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Console.CustomConfigSection/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Console.CustomConfigSection/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Console.CustomConfigSection/.github/prompts/nano-remove-entity.prompt.md b/Console.CustomConfigSection/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Console.CustomConfigSection/.github/prompts/nano-remove-entity.prompt.md +++ b/Console.CustomConfigSection/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Console.CustomConfigSection/AGENTS.md b/Console.CustomConfigSection/AGENTS.md index 26f63772..3805edf2 100644 --- a/Console.CustomConfigSection/AGENTS.md +++ b/Console.CustomConfigSection/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Console.CustomService/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Console.CustomService/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Console.CustomService/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Console.CustomService/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 100644 --- a/Console.CustomService/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Console.CustomService/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller diff --git a/Console.CustomService/.claude/skills/nano-add-entity/SKILL.md b/Console.CustomService/.claude/skills/nano-add-entity/SKILL.md index 994e8e3f..d9f45740 100644 --- a/Console.CustomService/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.CustomService/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 100644 --- a/Console.CustomService/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Console.CustomService/.claude/skills/nano-add-postman-collection/SKILL.md @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Console.CustomService/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Console.CustomService/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Console.CustomService/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Console.CustomService/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Console.CustomService/.claude/skills/nano-remove-entity/SKILL.md b/Console.CustomService/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Console.CustomService/.claude/skills/nano-remove-entity/SKILL.md +++ b/Console.CustomService/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Console.CustomService/.github/prompts/nano-add-api-client-configuration.prompt.md b/Console.CustomService/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Console.CustomService/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Console.CustomService/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 100644 --- a/Console.CustomService/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Console.CustomService/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller diff --git a/Console.CustomService/.github/prompts/nano-add-entity.prompt.md b/Console.CustomService/.github/prompts/nano-add-entity.prompt.md index 75ed9535..f8345ca6 100644 --- a/Console.CustomService/.github/prompts/nano-add-entity.prompt.md +++ b/Console.CustomService/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 100644 --- a/Console.CustomService/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Console.CustomService/.github/prompts/nano-add-postman-collection.prompt.md @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Console.CustomService/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Console.CustomService/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Console.CustomService/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Console.CustomService/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Console.CustomService/.github/prompts/nano-remove-entity.prompt.md b/Console.CustomService/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Console.CustomService/.github/prompts/nano-remove-entity.prompt.md +++ b/Console.CustomService/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Console.CustomService/AGENTS.md b/Console.CustomService/AGENTS.md index 26f63772..3805edf2 100644 --- a/Console.CustomService/AGENTS.md +++ b/Console.CustomService/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Console.Data.InMemory/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Console.Data.InMemory/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Console.Data.InMemory/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Console.Data.InMemory/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 994e8e3f..d9f45740 100644 --- a/Console.Data.InMemory/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Data.InMemory/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Console.Data.InMemory/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Console.Data.InMemory/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Console.Data.InMemory/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Console.Data.InMemory/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Console.Data.InMemory/.claude/skills/nano-remove-entity/SKILL.md b/Console.Data.InMemory/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Console.Data.InMemory/.claude/skills/nano-remove-entity/SKILL.md +++ b/Console.Data.InMemory/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Console.Data.InMemory/.github/prompts/nano-add-api-client-configuration.prompt.md b/Console.Data.InMemory/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Console.Data.InMemory/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Console.Data.InMemory/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 75ed9535..f8345ca6 100644 --- a/Console.Data.InMemory/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Data.InMemory/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Console.Data.InMemory/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Console.Data.InMemory/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Console.Data.InMemory/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Console.Data.InMemory/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Console.Data.InMemory/.github/prompts/nano-remove-entity.prompt.md b/Console.Data.InMemory/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Console.Data.InMemory/.github/prompts/nano-remove-entity.prompt.md +++ b/Console.Data.InMemory/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Console.Data.InMemory/AGENTS.md b/Console.Data.InMemory/AGENTS.md index 26f63772..3805edf2 100644 --- a/Console.Data.InMemory/AGENTS.md +++ b/Console.Data.InMemory/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Console.Data.MySql/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Console.Data.MySql/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Console.Data.MySql/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Console.Data.MySql/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 994e8e3f..d9f45740 100644 --- a/Console.Data.MySql/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Data.MySql/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Console.Data.MySql/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Console.Data.MySql/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Console.Data.MySql/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Console.Data.MySql/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Console.Data.MySql/.claude/skills/nano-remove-entity/SKILL.md b/Console.Data.MySql/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Console.Data.MySql/.claude/skills/nano-remove-entity/SKILL.md +++ b/Console.Data.MySql/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Console.Data.MySql/.github/prompts/nano-add-api-client-configuration.prompt.md b/Console.Data.MySql/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Console.Data.MySql/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Console.Data.MySql/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 75ed9535..f8345ca6 100644 --- a/Console.Data.MySql/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Data.MySql/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Console.Data.MySql/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Console.Data.MySql/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Console.Data.MySql/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Console.Data.MySql/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Console.Data.MySql/.github/prompts/nano-remove-entity.prompt.md b/Console.Data.MySql/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Console.Data.MySql/.github/prompts/nano-remove-entity.prompt.md +++ b/Console.Data.MySql/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Console.Data.MySql/AGENTS.md b/Console.Data.MySql/AGENTS.md index 26f63772..3805edf2 100644 --- a/Console.Data.MySql/AGENTS.md +++ b/Console.Data.MySql/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Console.Data.PostgreSQL/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Console.Data.PostgreSQL/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Console.Data.PostgreSQL/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Console.Data.PostgreSQL/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 994e8e3f..d9f45740 100644 --- a/Console.Data.PostgreSQL/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Data.PostgreSQL/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Console.Data.PostgreSQL/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Console.Data.PostgreSQL/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Console.Data.PostgreSQL/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Console.Data.PostgreSQL/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Console.Data.PostgreSQL/.claude/skills/nano-remove-entity/SKILL.md b/Console.Data.PostgreSQL/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Console.Data.PostgreSQL/.claude/skills/nano-remove-entity/SKILL.md +++ b/Console.Data.PostgreSQL/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Console.Data.PostgreSQL/.github/prompts/nano-add-api-client-configuration.prompt.md b/Console.Data.PostgreSQL/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Console.Data.PostgreSQL/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Console.Data.PostgreSQL/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 75ed9535..f8345ca6 100644 --- a/Console.Data.PostgreSQL/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Data.PostgreSQL/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Console.Data.PostgreSQL/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Console.Data.PostgreSQL/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Console.Data.PostgreSQL/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Console.Data.PostgreSQL/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Console.Data.PostgreSQL/.github/prompts/nano-remove-entity.prompt.md b/Console.Data.PostgreSQL/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Console.Data.PostgreSQL/.github/prompts/nano-remove-entity.prompt.md +++ b/Console.Data.PostgreSQL/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Console.Data.PostgreSQL/AGENTS.md b/Console.Data.PostgreSQL/AGENTS.md index 26f63772..3805edf2 100644 --- a/Console.Data.PostgreSQL/AGENTS.md +++ b/Console.Data.PostgreSQL/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Console.Data.SqLite/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Console.Data.SqLite/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Console.Data.SqLite/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Console.Data.SqLite/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 994e8e3f..d9f45740 100644 --- a/Console.Data.SqLite/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Data.SqLite/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Console.Data.SqLite/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Console.Data.SqLite/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Console.Data.SqLite/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Console.Data.SqLite/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Console.Data.SqLite/.claude/skills/nano-remove-entity/SKILL.md b/Console.Data.SqLite/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Console.Data.SqLite/.claude/skills/nano-remove-entity/SKILL.md +++ b/Console.Data.SqLite/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Console.Data.SqLite/.github/prompts/nano-add-api-client-configuration.prompt.md b/Console.Data.SqLite/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Console.Data.SqLite/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Console.Data.SqLite/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 75ed9535..f8345ca6 100644 --- a/Console.Data.SqLite/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Data.SqLite/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Console.Data.SqLite/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Console.Data.SqLite/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Console.Data.SqLite/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Console.Data.SqLite/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Console.Data.SqLite/.github/prompts/nano-remove-entity.prompt.md b/Console.Data.SqLite/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Console.Data.SqLite/.github/prompts/nano-remove-entity.prompt.md +++ b/Console.Data.SqLite/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Console.Data.SqLite/AGENTS.md b/Console.Data.SqLite/AGENTS.md index 26f63772..3805edf2 100644 --- a/Console.Data.SqLite/AGENTS.md +++ b/Console.Data.SqLite/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Console.Data.SqlServer/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Console.Data.SqlServer/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Console.Data.SqlServer/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Console.Data.SqlServer/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 994e8e3f..d9f45740 100644 --- a/Console.Data.SqlServer/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Data.SqlServer/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Console.Data.SqlServer/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Console.Data.SqlServer/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Console.Data.SqlServer/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Console.Data.SqlServer/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Console.Data.SqlServer/.claude/skills/nano-remove-entity/SKILL.md b/Console.Data.SqlServer/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Console.Data.SqlServer/.claude/skills/nano-remove-entity/SKILL.md +++ b/Console.Data.SqlServer/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Console.Data.SqlServer/.github/prompts/nano-add-api-client-configuration.prompt.md b/Console.Data.SqlServer/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Console.Data.SqlServer/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Console.Data.SqlServer/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 75ed9535..f8345ca6 100644 --- a/Console.Data.SqlServer/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Data.SqlServer/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Console.Data.SqlServer/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Console.Data.SqlServer/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Console.Data.SqlServer/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Console.Data.SqlServer/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Console.Data.SqlServer/.github/prompts/nano-remove-entity.prompt.md b/Console.Data.SqlServer/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Console.Data.SqlServer/.github/prompts/nano-remove-entity.prompt.md +++ b/Console.Data.SqlServer/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Console.Data.SqlServer/AGENTS.md b/Console.Data.SqlServer/AGENTS.md index 26f63772..3805edf2 100644 --- a/Console.Data.SqlServer/AGENTS.md +++ b/Console.Data.SqlServer/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Console.Eventing.RabbitMq/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Console.Eventing.RabbitMq/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Console.Eventing.RabbitMq/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Console.Eventing.RabbitMq/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 994e8e3f..d9f45740 100644 --- a/Console.Eventing.RabbitMq/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Eventing.RabbitMq/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Console.Eventing.RabbitMq/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Console.Eventing.RabbitMq/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Console.Eventing.RabbitMq/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Console.Eventing.RabbitMq/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Console.Eventing.RabbitMq/.claude/skills/nano-remove-entity/SKILL.md b/Console.Eventing.RabbitMq/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Console.Eventing.RabbitMq/.claude/skills/nano-remove-entity/SKILL.md +++ b/Console.Eventing.RabbitMq/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Console.Eventing.RabbitMq/.github/prompts/nano-add-api-client-configuration.prompt.md b/Console.Eventing.RabbitMq/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Console.Eventing.RabbitMq/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Console.Eventing.RabbitMq/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 75ed9535..f8345ca6 100644 --- a/Console.Eventing.RabbitMq/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Eventing.RabbitMq/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Console.Eventing.RabbitMq/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Console.Eventing.RabbitMq/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Console.Eventing.RabbitMq/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Console.Eventing.RabbitMq/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Console.Eventing.RabbitMq/.github/prompts/nano-remove-entity.prompt.md b/Console.Eventing.RabbitMq/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Console.Eventing.RabbitMq/.github/prompts/nano-remove-entity.prompt.md +++ b/Console.Eventing.RabbitMq/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Console.Eventing.RabbitMq/AGENTS.md b/Console.Eventing.RabbitMq/AGENTS.md index 26f63772..3805edf2 100644 --- a/Console.Eventing.RabbitMq/AGENTS.md +++ b/Console.Eventing.RabbitMq/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Console.ExceptionHandling/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Console.ExceptionHandling/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Console.ExceptionHandling/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Console.ExceptionHandling/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 100644 --- a/Console.ExceptionHandling/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Console.ExceptionHandling/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller diff --git a/Console.ExceptionHandling/.claude/skills/nano-add-entity/SKILL.md b/Console.ExceptionHandling/.claude/skills/nano-add-entity/SKILL.md index 994e8e3f..d9f45740 100644 --- a/Console.ExceptionHandling/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.ExceptionHandling/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 100644 --- a/Console.ExceptionHandling/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Console.ExceptionHandling/.claude/skills/nano-add-postman-collection/SKILL.md @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Console.ExceptionHandling/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Console.ExceptionHandling/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Console.ExceptionHandling/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Console.ExceptionHandling/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Console.ExceptionHandling/.claude/skills/nano-remove-entity/SKILL.md b/Console.ExceptionHandling/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Console.ExceptionHandling/.claude/skills/nano-remove-entity/SKILL.md +++ b/Console.ExceptionHandling/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Console.ExceptionHandling/.github/prompts/nano-add-api-client-configuration.prompt.md b/Console.ExceptionHandling/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Console.ExceptionHandling/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Console.ExceptionHandling/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 100644 --- a/Console.ExceptionHandling/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Console.ExceptionHandling/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller diff --git a/Console.ExceptionHandling/.github/prompts/nano-add-entity.prompt.md b/Console.ExceptionHandling/.github/prompts/nano-add-entity.prompt.md index 75ed9535..f8345ca6 100644 --- a/Console.ExceptionHandling/.github/prompts/nano-add-entity.prompt.md +++ b/Console.ExceptionHandling/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 100644 --- a/Console.ExceptionHandling/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Console.ExceptionHandling/.github/prompts/nano-add-postman-collection.prompt.md @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Console.ExceptionHandling/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Console.ExceptionHandling/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Console.ExceptionHandling/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Console.ExceptionHandling/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Console.ExceptionHandling/.github/prompts/nano-remove-entity.prompt.md b/Console.ExceptionHandling/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Console.ExceptionHandling/.github/prompts/nano-remove-entity.prompt.md +++ b/Console.ExceptionHandling/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Console.ExceptionHandling/AGENTS.md b/Console.ExceptionHandling/AGENTS.md index 26f63772..3805edf2 100644 --- a/Console.ExceptionHandling/AGENTS.md +++ b/Console.ExceptionHandling/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Console.Localization/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Console.Localization/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Console.Localization/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Console.Localization/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 100644 --- a/Console.Localization/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Console.Localization/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller diff --git a/Console.Localization/.claude/skills/nano-add-entity/SKILL.md b/Console.Localization/.claude/skills/nano-add-entity/SKILL.md index 994e8e3f..d9f45740 100644 --- a/Console.Localization/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Localization/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 100644 --- a/Console.Localization/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Console.Localization/.claude/skills/nano-add-postman-collection/SKILL.md @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Console.Localization/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Console.Localization/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Console.Localization/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Console.Localization/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Console.Localization/.claude/skills/nano-remove-entity/SKILL.md b/Console.Localization/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Console.Localization/.claude/skills/nano-remove-entity/SKILL.md +++ b/Console.Localization/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Console.Localization/.github/prompts/nano-add-api-client-configuration.prompt.md b/Console.Localization/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Console.Localization/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Console.Localization/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 100644 --- a/Console.Localization/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Console.Localization/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller diff --git a/Console.Localization/.github/prompts/nano-add-entity.prompt.md b/Console.Localization/.github/prompts/nano-add-entity.prompt.md index 75ed9535..f8345ca6 100644 --- a/Console.Localization/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Localization/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 100644 --- a/Console.Localization/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Console.Localization/.github/prompts/nano-add-postman-collection.prompt.md @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Console.Localization/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Console.Localization/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Console.Localization/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Console.Localization/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Console.Localization/.github/prompts/nano-remove-entity.prompt.md b/Console.Localization/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Console.Localization/.github/prompts/nano-remove-entity.prompt.md +++ b/Console.Localization/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Console.Localization/AGENTS.md b/Console.Localization/AGENTS.md index 26f63772..3805edf2 100644 --- a/Console.Localization/AGENTS.md +++ b/Console.Localization/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Console.Logging.Log4Net/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Console.Logging.Log4Net/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Console.Logging.Log4Net/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Console.Logging.Log4Net/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 994e8e3f..d9f45740 100644 --- a/Console.Logging.Log4Net/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Logging.Log4Net/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Console.Logging.Log4Net/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Console.Logging.Log4Net/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Console.Logging.Log4Net/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Console.Logging.Log4Net/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Console.Logging.Log4Net/.claude/skills/nano-remove-entity/SKILL.md b/Console.Logging.Log4Net/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Console.Logging.Log4Net/.claude/skills/nano-remove-entity/SKILL.md +++ b/Console.Logging.Log4Net/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Console.Logging.Log4Net/.github/prompts/nano-add-api-client-configuration.prompt.md b/Console.Logging.Log4Net/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Console.Logging.Log4Net/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Console.Logging.Log4Net/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 75ed9535..f8345ca6 100644 --- a/Console.Logging.Log4Net/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Logging.Log4Net/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Console.Logging.Log4Net/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Console.Logging.Log4Net/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Console.Logging.Log4Net/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Console.Logging.Log4Net/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Console.Logging.Log4Net/.github/prompts/nano-remove-entity.prompt.md b/Console.Logging.Log4Net/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Console.Logging.Log4Net/.github/prompts/nano-remove-entity.prompt.md +++ b/Console.Logging.Log4Net/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Console.Logging.Log4Net/AGENTS.md b/Console.Logging.Log4Net/AGENTS.md index 26f63772..3805edf2 100644 --- a/Console.Logging.Log4Net/AGENTS.md +++ b/Console.Logging.Log4Net/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Console.Logging.Microsoft/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Console.Logging.Microsoft/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Console.Logging.Microsoft/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Console.Logging.Microsoft/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 994e8e3f..d9f45740 100644 --- a/Console.Logging.Microsoft/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Logging.Microsoft/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Console.Logging.Microsoft/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Console.Logging.Microsoft/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Console.Logging.Microsoft/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Console.Logging.Microsoft/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Console.Logging.Microsoft/.claude/skills/nano-remove-entity/SKILL.md b/Console.Logging.Microsoft/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Console.Logging.Microsoft/.claude/skills/nano-remove-entity/SKILL.md +++ b/Console.Logging.Microsoft/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Console.Logging.Microsoft/.github/prompts/nano-add-api-client-configuration.prompt.md b/Console.Logging.Microsoft/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Console.Logging.Microsoft/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Console.Logging.Microsoft/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 75ed9535..f8345ca6 100644 --- a/Console.Logging.Microsoft/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Logging.Microsoft/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Console.Logging.Microsoft/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Console.Logging.Microsoft/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Console.Logging.Microsoft/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Console.Logging.Microsoft/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Console.Logging.Microsoft/.github/prompts/nano-remove-entity.prompt.md b/Console.Logging.Microsoft/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Console.Logging.Microsoft/.github/prompts/nano-remove-entity.prompt.md +++ b/Console.Logging.Microsoft/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Console.Logging.Microsoft/AGENTS.md b/Console.Logging.Microsoft/AGENTS.md index 26f63772..3805edf2 100644 --- a/Console.Logging.Microsoft/AGENTS.md +++ b/Console.Logging.Microsoft/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Console.Logging.NLog/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Console.Logging.NLog/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Console.Logging.NLog/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Console.Logging.NLog/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 994e8e3f..d9f45740 100644 --- a/Console.Logging.NLog/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Logging.NLog/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Console.Logging.NLog/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Console.Logging.NLog/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Console.Logging.NLog/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Console.Logging.NLog/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Console.Logging.NLog/.claude/skills/nano-remove-entity/SKILL.md b/Console.Logging.NLog/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Console.Logging.NLog/.claude/skills/nano-remove-entity/SKILL.md +++ b/Console.Logging.NLog/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Console.Logging.NLog/.github/prompts/nano-add-api-client-configuration.prompt.md b/Console.Logging.NLog/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Console.Logging.NLog/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Console.Logging.NLog/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 75ed9535..f8345ca6 100644 --- a/Console.Logging.NLog/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Logging.NLog/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Console.Logging.NLog/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Console.Logging.NLog/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Console.Logging.NLog/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Console.Logging.NLog/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Console.Logging.NLog/.github/prompts/nano-remove-entity.prompt.md b/Console.Logging.NLog/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Console.Logging.NLog/.github/prompts/nano-remove-entity.prompt.md +++ b/Console.Logging.NLog/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Console.Logging.NLog/AGENTS.md b/Console.Logging.NLog/AGENTS.md index 26f63772..3805edf2 100644 --- a/Console.Logging.NLog/AGENTS.md +++ b/Console.Logging.NLog/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Console.Logging.Serilog/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Console.Logging.Serilog/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Console.Logging.Serilog/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Console.Logging.Serilog/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 994e8e3f..d9f45740 100644 --- a/Console.Logging.Serilog/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Logging.Serilog/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Console.Logging.Serilog/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Console.Logging.Serilog/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Console.Logging.Serilog/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Console.Logging.Serilog/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Console.Logging.Serilog/.claude/skills/nano-remove-entity/SKILL.md b/Console.Logging.Serilog/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Console.Logging.Serilog/.claude/skills/nano-remove-entity/SKILL.md +++ b/Console.Logging.Serilog/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Console.Logging.Serilog/.github/prompts/nano-add-api-client-configuration.prompt.md b/Console.Logging.Serilog/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Console.Logging.Serilog/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Console.Logging.Serilog/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 75ed9535..f8345ca6 100644 --- a/Console.Logging.Serilog/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Logging.Serilog/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Console.Logging.Serilog/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Console.Logging.Serilog/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Console.Logging.Serilog/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Console.Logging.Serilog/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Console.Logging.Serilog/.github/prompts/nano-remove-entity.prompt.md b/Console.Logging.Serilog/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Console.Logging.Serilog/.github/prompts/nano-remove-entity.prompt.md +++ b/Console.Logging.Serilog/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Console.Logging.Serilog/AGENTS.md b/Console.Logging.Serilog/AGENTS.md index 26f63772..3805edf2 100644 --- a/Console.Logging.Serilog/AGENTS.md +++ b/Console.Logging.Serilog/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Console.StartupTasks/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Console.StartupTasks/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Console.StartupTasks/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Console.StartupTasks/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 100644 --- a/Console.StartupTasks/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Console.StartupTasks/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller diff --git a/Console.StartupTasks/.claude/skills/nano-add-entity/SKILL.md b/Console.StartupTasks/.claude/skills/nano-add-entity/SKILL.md index 994e8e3f..d9f45740 100644 --- a/Console.StartupTasks/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.StartupTasks/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 100644 --- a/Console.StartupTasks/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Console.StartupTasks/.claude/skills/nano-add-postman-collection/SKILL.md @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Console.StartupTasks/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Console.StartupTasks/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Console.StartupTasks/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Console.StartupTasks/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Console.StartupTasks/.claude/skills/nano-remove-entity/SKILL.md b/Console.StartupTasks/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Console.StartupTasks/.claude/skills/nano-remove-entity/SKILL.md +++ b/Console.StartupTasks/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Console.StartupTasks/.github/prompts/nano-add-api-client-configuration.prompt.md b/Console.StartupTasks/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Console.StartupTasks/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Console.StartupTasks/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 100644 --- a/Console.StartupTasks/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Console.StartupTasks/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller diff --git a/Console.StartupTasks/.github/prompts/nano-add-entity.prompt.md b/Console.StartupTasks/.github/prompts/nano-add-entity.prompt.md index 75ed9535..f8345ca6 100644 --- a/Console.StartupTasks/.github/prompts/nano-add-entity.prompt.md +++ b/Console.StartupTasks/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 100644 --- a/Console.StartupTasks/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Console.StartupTasks/.github/prompts/nano-add-postman-collection.prompt.md @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Console.StartupTasks/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Console.StartupTasks/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Console.StartupTasks/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Console.StartupTasks/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Console.StartupTasks/.github/prompts/nano-remove-entity.prompt.md b/Console.StartupTasks/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Console.StartupTasks/.github/prompts/nano-remove-entity.prompt.md +++ b/Console.StartupTasks/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Console.StartupTasks/AGENTS.md b/Console.StartupTasks/AGENTS.md index 26f63772..3805edf2 100644 --- a/Console.StartupTasks/AGENTS.md +++ b/Console.StartupTasks/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Console.Storage.Azure/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Console.Storage.Azure/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Console.Storage.Azure/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Console.Storage.Azure/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 994e8e3f..d9f45740 100644 --- a/Console.Storage.Azure/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Storage.Azure/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Console.Storage.Azure/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Console.Storage.Azure/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Console.Storage.Azure/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Console.Storage.Azure/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Console.Storage.Azure/.claude/skills/nano-remove-entity/SKILL.md b/Console.Storage.Azure/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Console.Storage.Azure/.claude/skills/nano-remove-entity/SKILL.md +++ b/Console.Storage.Azure/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Console.Storage.Azure/.github/prompts/nano-add-api-client-configuration.prompt.md b/Console.Storage.Azure/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Console.Storage.Azure/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Console.Storage.Azure/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 75ed9535..f8345ca6 100644 --- a/Console.Storage.Azure/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Storage.Azure/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Console.Storage.Azure/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Console.Storage.Azure/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Console.Storage.Azure/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Console.Storage.Azure/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Console.Storage.Azure/.github/prompts/nano-remove-entity.prompt.md b/Console.Storage.Azure/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Console.Storage.Azure/.github/prompts/nano-remove-entity.prompt.md +++ b/Console.Storage.Azure/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Console.Storage.Azure/AGENTS.md b/Console.Storage.Azure/AGENTS.md index 26f63772..3805edf2 100644 --- a/Console.Storage.Azure/AGENTS.md +++ b/Console.Storage.Azure/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Console.Storage.Local/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Console.Storage.Local/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Console.Storage.Local/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Console.Storage.Local/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 994e8e3f..d9f45740 100644 --- a/Console.Storage.Local/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Storage.Local/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Console.Storage.Local/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Console.Storage.Local/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Console.Storage.Local/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Console.Storage.Local/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Console.Storage.Local/.claude/skills/nano-remove-entity/SKILL.md b/Console.Storage.Local/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Console.Storage.Local/.claude/skills/nano-remove-entity/SKILL.md +++ b/Console.Storage.Local/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Console.Storage.Local/.github/prompts/nano-add-api-client-configuration.prompt.md b/Console.Storage.Local/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Console.Storage.Local/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Console.Storage.Local/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 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 @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller 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 75ed9535..f8345ca6 100644 --- a/Console.Storage.Local/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Storage.Local/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 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 @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Console.Storage.Local/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Console.Storage.Local/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Console.Storage.Local/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Console.Storage.Local/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Console.Storage.Local/.github/prompts/nano-remove-entity.prompt.md b/Console.Storage.Local/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Console.Storage.Local/.github/prompts/nano-remove-entity.prompt.md +++ b/Console.Storage.Local/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Console.Storage.Local/AGENTS.md b/Console.Storage.Local/AGENTS.md index 26f63772..3805edf2 100644 --- a/Console.Storage.Local/AGENTS.md +++ b/Console.Storage.Local/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Console.Workers/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Console.Workers/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Console.Workers/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Console.Workers/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 100644 --- a/Console.Workers/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Console.Workers/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller diff --git a/Console.Workers/.claude/skills/nano-add-entity/SKILL.md b/Console.Workers/.claude/skills/nano-add-entity/SKILL.md index 994e8e3f..d9f45740 100644 --- a/Console.Workers/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Workers/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 100644 --- a/Console.Workers/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Console.Workers/.claude/skills/nano-add-postman-collection/SKILL.md @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Console.Workers/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Console.Workers/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Console.Workers/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Console.Workers/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Console.Workers/.claude/skills/nano-remove-entity/SKILL.md b/Console.Workers/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Console.Workers/.claude/skills/nano-remove-entity/SKILL.md +++ b/Console.Workers/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Console.Workers/.github/prompts/nano-add-api-client-configuration.prompt.md b/Console.Workers/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Console.Workers/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Console.Workers/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 100644 --- a/Console.Workers/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Console.Workers/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller diff --git a/Console.Workers/.github/prompts/nano-add-entity.prompt.md b/Console.Workers/.github/prompts/nano-add-entity.prompt.md index 75ed9535..f8345ca6 100644 --- a/Console.Workers/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Workers/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 100644 --- a/Console.Workers/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Console.Workers/.github/prompts/nano-add-postman-collection.prompt.md @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Console.Workers/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Console.Workers/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Console.Workers/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Console.Workers/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Console.Workers/.github/prompts/nano-remove-entity.prompt.md b/Console.Workers/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Console.Workers/.github/prompts/nano-remove-entity.prompt.md +++ b/Console.Workers/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Console.Workers/AGENTS.md b/Console.Workers/AGENTS.md index 26f63772..3805edf2 100644 --- a/Console.Workers/AGENTS.md +++ b/Console.Workers/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Console._Blank/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Console._Blank/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Console._Blank/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Console._Blank/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 100644 --- a/Console._Blank/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Console._Blank/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller diff --git a/Console._Blank/.claude/skills/nano-add-entity/SKILL.md b/Console._Blank/.claude/skills/nano-add-entity/SKILL.md index 994e8e3f..d9f45740 100644 --- a/Console._Blank/.claude/skills/nano-add-entity/SKILL.md +++ b/Console._Blank/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 100644 --- a/Console._Blank/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Console._Blank/.claude/skills/nano-add-postman-collection/SKILL.md @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Console._Blank/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Console._Blank/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Console._Blank/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Console._Blank/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Console._Blank/.claude/skills/nano-remove-entity/SKILL.md b/Console._Blank/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Console._Blank/.claude/skills/nano-remove-entity/SKILL.md +++ b/Console._Blank/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Console._Blank/.github/prompts/nano-add-api-client-configuration.prompt.md b/Console._Blank/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Console._Blank/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Console._Blank/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 100644 --- a/Console._Blank/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Console._Blank/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller diff --git a/Console._Blank/.github/prompts/nano-add-entity.prompt.md b/Console._Blank/.github/prompts/nano-add-entity.prompt.md index 75ed9535..f8345ca6 100644 --- a/Console._Blank/.github/prompts/nano-add-entity.prompt.md +++ b/Console._Blank/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 100644 --- a/Console._Blank/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Console._Blank/.github/prompts/nano-add-postman-collection.prompt.md @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Console._Blank/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Console._Blank/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Console._Blank/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Console._Blank/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Console._Blank/.github/prompts/nano-remove-entity.prompt.md b/Console._Blank/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Console._Blank/.github/prompts/nano-remove-entity.prompt.md +++ b/Console._Blank/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Console._Blank/AGENTS.md b/Console._Blank/AGENTS.md index 26f63772..3805edf2 100644 --- a/Console._Blank/AGENTS.md +++ b/Console._Blank/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | diff --git a/Web._Blank/.claude/skills/nano-add-api-client-configuration/SKILL.md b/Web._Blank/.claude/skills/nano-add-api-client-configuration/SKILL.md index 61fdfd23..c535d713 100644 --- a/Web._Blank/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/Web._Blank/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 531bfe11..48e69388 100644 --- a/Web._Blank/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Web._Blank/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,24 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller diff --git a/Web._Blank/.claude/skills/nano-add-entity/SKILL.md b/Web._Blank/.claude/skills/nano-add-entity/SKILL.md index 994e8e3f..d9f45740 100644 --- a/Web._Blank/.claude/skills/nano-add-entity/SKILL.md +++ b/Web._Blank/.claude/skills/nano-add-entity/SKILL.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 — Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 bfe4b2d3..311a1bb7 100644 --- a/Web._Blank/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Web._Blank/.claude/skills/nano-add-postman-collection/SKILL.md @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). diff --git a/Web._Blank/.claude/skills/nano-remove-custom-endpoint/SKILL.md b/Web._Blank/.claude/skills/nano-remove-custom-endpoint/SKILL.md index cecab0a4..60483e6c 100644 --- a/Web._Blank/.claude/skills/nano-remove-custom-endpoint/SKILL.md +++ b/Web._Blank/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Web._Blank/.claude/skills/nano-remove-entity/SKILL.md b/Web._Blank/.claude/skills/nano-remove-entity/SKILL.md index a938a28e..1acdafb2 100644 --- a/Web._Blank/.claude/skills/nano-remove-entity/SKILL.md +++ b/Web._Blank/.claude/skills/nano-remove-entity/SKILL.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Web._Blank/.github/prompts/nano-add-api-client-configuration.prompt.md b/Web._Blank/.github/prompts/nano-add-api-client-configuration.prompt.md index f21ad234..5094df3c 100644 --- a/Web._Blank/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/Web._Blank/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -29,6 +29,22 @@ takes effect. ## Before making any change, determine +0. **Is the client already referenced by this application, and if not, where is it?** Do this first. + - **Already referenced:** if this application's project (`{name}/{name}.csproj`) already has a + `ProjectReference`/`PackageReference` that provides the client class, nothing to add - continue. + - **Not referenced, and the user named the target service** (e.g. "add api client configuration for + `Svc.Something`"): search the **root directory** - one level up from this application's own solution + directory, where every sibling solution lives - for a solution folder with that name and its + `{TargetName}.Models` project. If found, add the `ProjectReference` (step 2) without asking again. + - **Not referenced, and the user did not name a target service** (only the client class): tell the user + the client could not be found in this application's references, and - if a sibling `{TargetName}.Models` + project containing it is found under the root directory - offer to add a project reference to it, + naming that project. Wait for their answer. + - **Not found anywhere locally, or several matches:** say so and ask - don't guess, and never invent a + package reference or version. + - **Whenever a project reference is (or is about to be) added, say upfront** that it is a + development-time reference: it must be replaced with the real NuGet `PackageReference` once the target + service's package is published, before this application is deployed. 1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the user means. If it doesn't exist yet, stop — don't create it inline, and don't invoke @@ -37,26 +53,20 @@ takes effect. wiring into right now, and if so, did they mean to create the client first (on the owning service's own project, a separate task from this one)? Let them answer both before doing anything else. -2. **How does this app reference the target's `.Models` project?** Check how any other Api - Client in this project already references its target (`ProjectReference` for a - same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo - target — the more common real-world case, since most Nano apps live in separate repos and - publish their `.Models` project as a private package) and match that convention — AGENTS.md - explicitly allows either here. If this is the first Api Client in the project, ask which - applies. - - **Then actually add the reference to this app's `.csproj` if it isn't already there** — don't - stop at determining which kind it should be. A missing reference here is a real, previously - observed gap (this app referencing a target's Api Client with no corresponding - `ProjectReference`/`PackageReference` at all, caught only when the build failed). - - **For a `PackageReference`, determine the version rather than guessing.** If the target's - source is locally available (a monorepo or multiple repos checked out side by side), read - its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of - inventing one. - - **Private feed authentication is the user's responsibility, not something to work around.** - If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and - restore fails for missing credentials, say so plainly and let the user resolve their own - `nuget.config`/feed auth — don't attempt to supply or guess at credentials yourself, and - don't silently fall back to a different reference shape to dodge the failure. +2. **Always add the reference to the consuming application's own project (`{name}/{name}.csproj`), never + to a `.Models` project** - even if this application happens to have one. A `.Models` project is shared + (published as a NuGet) with other applications, so a reference to another service's `.Models` placed + there would leak into every consumer of it, which it shouldn't. Most consumers (a Public API, a Console + application) have no `.Models` project at all. + - Use a relative `ProjectReference` to the target's `{TargetName}.Models.csproj` found in step 0. Match + how the project's other Api Client references are written where that applies. + - **Never write a `PackageReference` or a version yourself** - the real version is injected by CI/CD, so + there is nothing reliable to read locally, and the package is on a private feed you have no business + querying. Switching to the package is the user's step (see "After making the change"). + - **Actually add the reference if it isn't already there** - a missing reference is a real, previously + observed gap (caught only when the build failed). + - If the application already references other targets by `PackageReference`, still add the + `ProjectReference` for this one, and say the same replace-later note applies. 3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key matching the class name — if one's already there pointing at a different host/target, confirm with the user before overwriting it. @@ -182,6 +192,9 @@ Console app's own compose service has no `ports` of its own to worry about colli ## After making the change +- **If a `ProjectReference` was added, remind the user:** remember to replace the project reference with + the real NuGet `PackageReference` once the target service's package is published, before deploying. + The skill never writes the package reference or version itself. - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore file touched by the section above. 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 65048c08..b45979d9 100644 --- a/Web._Blank/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Web._Blank/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -227,6 +227,14 @@ Location: `Requests//Request.cs` in the Public API's own app proj 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. +`` is always the **route name of the controller that hosts the action** (its class name without +`Controller`, e.g. a controller named `FooBarsController` uses `FooBars`), for both `Requests/` and +`Responses/`. One folder per controller; never group DTOs by another axis (a shared `Common/` folder, or a +folder named after a sub-entity the controller also serves). A DTO used by several controllers lives in the +folder of the controller that returns it, or is duplicated per controller if each needs its own shape. A +request/response in a folder that doesn't match its controller is a defect: move it and fix the namespace +and `using`s. + ```csharp public class Request { @@ -278,6 +286,20 @@ public virtual async Task Async([FromBody][Required] - 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 + target service reload the entity with its includes, which is wasted work when the action discards the + result (a bare `return this.Ok();`, a join-row insert, a fire-and-forget update). Use plain + `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` + variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing + row's content isn't needed. +- **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only + enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely + required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured + closure), or assigned to a persisted collection property. Then prefer `.ToArray()` (the most minimal + 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 calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. @@ -451,13 +473,23 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for 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 + a `List` is truly needed (e.g. `AddRange`). +- **Persist every change to a loaded entity with an explicit `this.Repository.UpdateAsync(entity, ...)`.** Don't + assign a property on an entity read through the repository and rely on it being tracked and written by a later + `AddAsync`/`DeleteAsync` save. Nano's `DbContext` overrides `Update` (it hydrates the entity graph so audit + captures the original vs new values), so an update that skips that path is not equivalent, and an explicit call + keeps the write visible on read. The price is a separate save per call instead of one transaction, so order the + calls where a unique index or foreign key needs it (e.g. close the old row before adding the row that replaces + it, delete before restoring a predecessor), and only rely on a single save when that atomicity genuinely matters. - **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 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`) + 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` 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 @@ -526,7 +558,7 @@ consumer, not just the one Public API that remembered to compose it. - **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 once a sibling entity's existence makes the parent immutable" is the same shape of check - (`CountAsync`/`QueryCountAsync` against the related entity, `throw BadRequestException()` if it's + (`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. @@ -555,6 +587,23 @@ consumer, not just the one Public API that remembered to compose it. --- +## Postman collection + +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 + 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 + 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 + nothing in Postman by itself. + +--- ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller diff --git a/Web._Blank/.github/prompts/nano-add-entity.prompt.md b/Web._Blank/.github/prompts/nano-add-entity.prompt.md index 75ed9535..f8345ca6 100644 --- a/Web._Blank/.github/prompts/nano-add-entity.prompt.md +++ b/Web._Blank/.github/prompts/nano-add-entity.prompt.md @@ -99,8 +99,8 @@ public class : BaseEntity existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. + `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 @@ -127,6 +127,16 @@ public class : BaseEntity `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. +- **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, + query criteria filters, requests, responses and their doc comments. +- **Derive, don't store, a value that is a pure function of other columns and the clock** (e.g. a lifecycle + status computed from `StartsAt`/`EndsAt`). Expose it as a getter-only property marked `[NotMapped]` (and + `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the + underlying columns. A stored status column for this goes stale, and a database generated column can't + reference the current time. ## File 2 - Data mapping @@ -214,7 +224,20 @@ public class Mapping : BaseEntityMapping<> 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 - when the join entity currently has no columns beyond the two FKs. + 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 + 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; + 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 + 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 `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` @@ -255,8 +278,11 @@ public class QueryCriteria : BaseQueryCriteria - 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 + 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. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. @@ -284,17 +310,34 @@ public class sController(ILogger<sController> logger, IRepositor - 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]`.** Having custom actions on the same + 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 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 - mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the - *only* case that changes the default tier. + 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 + 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 automatically from the assembly. +## 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 +`nano-add-postman-collection` skill's conventions: only the generic actions the controller's actual base +class exposes (see File 4), placed in FK-dependency order, with Id/name chaining test scripts. Edit only the +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 +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 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 4d623510..dca9168b 100644 --- a/Web._Blank/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Web._Blank/.github/prompts/nano-add-postman-collection.prompt.md @@ -36,6 +36,8 @@ it impossible to swap the Postman auth token between apps without stepping on an No Create, no Delete. - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. + - `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 now that both halves exist. - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). diff --git a/Web._Blank/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Web._Blank/.github/prompts/nano-remove-custom-endpoint.prompt.md index c52afa7f..aaf4102f 100644 --- a/Web._Blank/.github/prompts/nano-remove-custom-endpoint.prompt.md +++ b/Web._Blank/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -184,6 +184,12 @@ because the override now covers it. --- +## Postman collection + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this endpoint's request from it (or, if the endpoint was converted rather than +removed, update that request to match). Edit only that request; don't regenerate the file. If the file was +edited, remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file/method touched or deleted, grouped by concern, and which path was diff --git a/Web._Blank/.github/prompts/nano-remove-entity.prompt.md b/Web._Blank/.github/prompts/nano-remove-entity.prompt.md index 30a10a98..bb1a0887 100644 --- a/Web._Blank/.github/prompts/nano-remove-entity.prompt.md +++ b/Web._Blank/.github/prompts/nano-remove-entity.prompt.md @@ -81,6 +81,12 @@ request that doesn't imply anything else in the app is changing. delete this before every other entity's relationship to it has been resolved, or you'll be fixing the same compile break twice. +## Postman collection (API/Web only) + +If the application already has a Postman collection (`Postman_.json` in the application's own folder, +next to its `README.md`), remove this entity's folder from it, and remove or fix any later request that chained +this entity's Id/name variables. Edit only what's affected; don't regenerate the file. If the file was edited, +remind the user to **Replace**-import it in Postman. If no collection exists, do nothing Postman-related. ## After making the change - Show the user every file deleted, and every other file touched to resolve step 2's diff --git a/Web._Blank/AGENTS.md b/Web._Blank/AGENTS.md index 26f63772..3805edf2 100644 --- a/Web._Blank/AGENTS.md +++ b/Web._Blank/AGENTS.md @@ -42,10 +42,10 @@ inside `{name}/`. | `{name}/Migrations/` | ✓ | ✓ | ✓ | EF Core migrations (conventional location, when a SQL data provider is used). | | `{name}/wwwroot/` | ✓ | ✓ | ✗ | Static/dynamic web content root. | | `{name}/Dockerfile.Local` | ✓ | ✓ | ✓ | Used by Docker Compose in `Development`; must stay in the application project folder. | -| `{name}.Models/{name}.Models.csproj` | ✓ | ✓ | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | -| `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | -| `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | -| `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Models/{name}.Models.csproj` | (✓) | (✓) | ✗ | Sibling project holding entity models, query criteria, and API client (Requests/Api). Publishable as its own NuGet for sharing models + API client with consumers. Should reference at minimum `Nano.App`. | +| `{name}.Models/Data/` | (✓) | (✓) | ✗ | Entity models (conventional location). | +| `{name}.Models/Criterias/` | (✓) | (✓) | ✗ | Query criteria classes (conventional location). | +| `{name}.Models/Api/` | (✓) | (✓) | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | | `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | @@ -69,6 +69,8 @@ inside `{name}/`. | `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | | `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | +**{name}.Models is optional.** Only an application that shares entity models, query criteria, or an Api Client with other applications needs one - in practice, internal services. A Public API (which composes other services' Api Clients) or a Console application normally has none, and nothing stops adding one if there's a real reason. An application consuming another service's Api Client never adds that reference to its own .Models project (if it has one): the reference goes on the application project, so it isn't leaked into everything that consumes this application's .Models. + Folder names like `Controllers/`, `Data/`, `Criterias/`, `Api/`, and `Migrations/` are convention, not a framework requirement — Nano discovers controllers, mappings, and data providers by type, not by folder location. As each feature section below is filled in, it will also note where new files of that kind @@ -81,7 +83,7 @@ Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kuberne line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named -package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references +package underneath it — either one works the same way) to `{name}.Models` only (or, for an application with no `.Models` project, to `{name}` itself) — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, @@ -1964,6 +1966,7 @@ capability you want to expose — each is gated by its own [authorization policy | `BaseEntityCreatableController` | ✓ | ✓ | ✓ | ✗ | ✗ | `NanoRead` + `NanoAdd` | | `BaseEntityEditableController` | ✓ | ✓ | ✗ | ✓ | ✗ | `NanoRead` + `NanoEdit` | | `BaseEntityCreatableAndEditableController` | ✓ | ✓ | ✓ | ✓ | ✗ | adds `NanoAddOrEdit` for the upsert action | +| `BaseEntityCreatableAndDeletableController` | ✓ | ✓ | ✓ | ✗ | ✓ | `NanoRead` + `NanoAdd` + `NanoDelete` - for entities only ever added and removed, e.g. a join row | | `BaseEntityDeletableController` | ✓ | ✓ | ✗ | ✗ | ✓ | `NanoRead` + `NanoDelete` | | `BaseEntityController` | ✓ | ✓ | ✓ | ✓ | ✓ | full set, adds `NanoDelete` on top | @@ -2033,16 +2036,21 @@ constructing one that isn't throws `InvalidOperationException` immediately: ##### Combining criteria — sequential, not grouped Every operation call takes a `LogicalType` (`And` default, or `Or`). Multiple calls on the **same** -`CriteriaExpression` combine strictly **left to right** — each new condition's `LogicalType` says how it joins -with everything accumulated *so far*, not with what comes after it. There is no parenthesized grouping within -one `CriteriaExpression`: +`CriteriaExpression` combine strictly **left to right** — each condition's `LogicalType` says how it joins +with the **next** condition added, not with the one before it, and the **last** condition's `LogicalType` is +ignored since nothing follows it. There is no parenthesized grouping within one `CriteriaExpression`: ```csharp -expression.Equal(nameof(MyEntity.A), a); // A -expression.Equal(nameof(MyEntity.B), b); // (A) AND B -expression.Equal(nameof(MyEntity.C), c, LogicalType.Or); // (A AND B) OR C -- NOT A AND (B OR C) +expression.Equal(nameof(MyEntity.A), a); // A, joined to B with AND (the default) +expression.Equal(nameof(MyEntity.B), b, LogicalType.Or); // (A AND B), joined to C with OR +expression.Equal(nameof(MyEntity.C), c); // (A AND B) OR C -- NOT A AND (B OR C); C's own type is ignored ``` +⚠ Because it's the *next* condition that a type binds to, matching one value against several columns (a keyword +search) needs `Or` on **every condition except the last**: `X(Or), Y(Or), Z` is `X OR Y OR Z`, whereas +`X, Y(Or), Z` — the reading most people expect from "Or on the later ones" — is `(X AND Y) OR Z`, which +silently returns almost nothing. + To get `A AND (B OR C)`, build `(B OR C)` as one `CriteriaExpression` and put `A` in a **separate** `CriteriaExpression` — the `IList` returned by `GetExpressions()` is itself combined with **AND across the list**, so multiple list entries behave like top-level AND-ed groups: @@ -2052,8 +2060,8 @@ var groupA = new CriteriaExpression(); groupA.Equal(nameof(MyEntity.A), a); var groupBC = new CriteriaExpression(); -groupBC.Equal(nameof(MyEntity.B), b); -groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.B), b, LogicalType.Or); +groupBC.Equal(nameof(MyEntity.C), c); return new[] { groupA, groupBC }; // A AND (B OR C) ``` @@ -2707,6 +2715,7 @@ IEntitySoftDeletable : IEntityDeletable, redeclares IsDeleted (opt-in) | `BaseEntityUpdatable` / `` | `IEntityUpdatable` only | | | `BaseEntityDeletable` / `` | `IEntityDeletable` only | | | `BaseEntityCreatableAndUpdatable` / `` | Create + update, no delete | | +| `BaseEntityCreatableAndDeletable` / `` | Create + delete, no update | For entities only ever added and removed, such as a join row between two entities. | | `BaseEntityUser` / `` | Update + delete (`IEntityUser`) | Adds `IdentityUser` (`IdentityUserEx`), tagged `[Include]` + `[ValidateNever]` + `[SwaggerRequestIgnore]` — always eager-loaded, never validated as input, never shown in Swagger request bodies. See [Identity](#identity). | | `BaseEntityView` | None — bare `IEntity`, no `Id`/`IsDeleted`/`CreatedAt` at all | Non-generic only. For entities mapped to a SQL view — you define every property yourself, including any identifier. | | `BaseEntityIdentity` / `` | `Id` only, nothing else | For advanced cases that don't want the built-in `IsDeleted`/`CreatedAt` — implement whichever capability interface (`IEntityCreatable`, `IEntityWritable`, etc.) yourself to restore the operations you need. | From 3c12674b3ec8bbbe0ff63e74aa17806e3459709a Mon Sep 17 00:00:00 2001 From: vivet Date: Mon, 28 Sep 2026 08:30:12 +0200 Subject: [PATCH 10/13] Updated skills, prompts and AGENTS.md --- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Api.ApiClients.Audit/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Api.ApiClients.Entity/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Api.ApiClients.RootLogIn/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Api.ApiClients/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Api.Auth.External.Custom/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Api.Auth.External.Microsoft/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Api.Auth.RootLogin/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Api.Authorization/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Api.ContentNegotiation/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Api.Cookies/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Api.CustomConfigSection/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Api.CustomMiddleware/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Api.CustomService/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Api.Data.Audit/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Api.Data.EntityEvents/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Api.Data.Identity.Auth.ApiKey/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ .../AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Api.Data.Identity.Auth.Jwt/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Api.Data.Identity/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Api.Data.InMemory/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Api.Data.LazyLoading/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Api.Data.MySql.Collation/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Api.Data.MySql.Mappings/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Api.Data.MySql.Spatial/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Api.Data.MySql.StoredProcedures/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Api.Data.MySql.Views/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Api.Data.MySql/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Api.Data.PostgreSQL.Spatial/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Api.Data.PostgreSQL/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Api.Data.Repository.AutoSave/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Api.Data.Repository.Includes/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Api.Data.SoftDelete/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Api.Data.SqLite/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Api.Data.SqlServer.Spatial/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Api.Data.SqlServer/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Api.Data.Triggers/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Api.Documentation.Csp/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Api.Documentation/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Api.ErrorHandling/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Api.Eventing.RabbitMq/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Api.HealthChecks/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Api.Hosting.Http/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Api.Hosting.Https/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Api.Hosting.MultipartLimits/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Api.Localization/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Api.Logging.Log4Net/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Api.Logging.Microsoft/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Api.Logging.NLog/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Api.Logging.Serilog/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Api.Metrics/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Api.MultipartJson/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ .../AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ .../AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Api.PolicyHeaders.Cors/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Api.PolicyHeaders.ForwardedHeaders/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Api.PolicyHeaders.FrameOptions/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Api.PolicyHeaders.Hsts/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Api.PolicyHeaders.ReferrerPolicy/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Api.PolicyHeaders.Robots/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Api.PolicyHeaders.XssProtection/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Api.RequestTracing/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Api.ResponseCache/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Api.ResponseCompression/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Api.Session/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Api.StartupTasks/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Api.StaticFiles/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Api.Storage.Azure/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Api.Storage.Local/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Api.TimeZone/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Api.Versioning/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Api.VirusScan/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Api._Blank/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Console.CustomConfigSection/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Console.CustomService/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Console.Data.InMemory/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Console.Data.MySql/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Console.Data.PostgreSQL/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Console.Data.SqLite/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Console.Data.SqlServer/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Console.Eventing.RabbitMq/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Console.ExceptionHandling/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Console.Localization/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Console.Logging.Log4Net/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Console.Logging.Microsoft/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Console.Logging.NLog/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Console.Logging.Serilog/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Console.StartupTasks/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Console.Storage.Azure/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Console.Storage.Local/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Console.Workers/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Console._Blank/AGENTS.md | 80 ++++++- .../SKILL.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt/SKILL.md | 54 +---- .../.claude/skills/nano-add-entity/SKILL.md | 11 +- .../SKILL.md | 81 +++++++ ...d-authentication-external-custom.prompt.md | 216 ++++++++++++++++++ .../nano-add-authentication-jwt.prompt.md | 54 +---- .../.github/prompts/nano-add-entity.prompt.md | 11 +- ...e-authentication-external-custom.prompt.md | 81 +++++++ Web._Blank/AGENTS.md | 80 ++++++- 828 files changed, 64768 insertions(+), 9200 deletions(-) create mode 100644 Api.ApiClients.Audit/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Api.ApiClients.Audit/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Api.ApiClients.Audit/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Api.ApiClients.Audit/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Api.ApiClients.Entity/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Api.ApiClients.Entity/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Api.ApiClients.Entity/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Api.ApiClients.Entity/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Api.ApiClients.RootLogIn/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Api.ApiClients.RootLogIn/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Api.ApiClients.RootLogIn/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Api.ApiClients.RootLogIn/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Api.ApiClients/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Api.ApiClients/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Api.ApiClients/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Api.ApiClients/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Api.Auth.External.Custom/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Api.Auth.External.Custom/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Api.Auth.External.Custom/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Api.Auth.External.Custom/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Api.Auth.External.Microsoft/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Api.Auth.External.Microsoft/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Api.Auth.External.Microsoft/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Api.Auth.External.Microsoft/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Api.Auth.RootLogin/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Api.Auth.RootLogin/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Api.Auth.RootLogin/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Api.Auth.RootLogin/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Api.Authorization/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Api.Authorization/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Api.Authorization/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Api.Authorization/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Api.ContentNegotiation/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Api.ContentNegotiation/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Api.ContentNegotiation/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Api.ContentNegotiation/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Api.Cookies/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Api.Cookies/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Api.Cookies/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Api.Cookies/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Api.CustomConfigSection/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Api.CustomConfigSection/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Api.CustomConfigSection/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Api.CustomConfigSection/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Api.CustomMiddleware/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Api.CustomMiddleware/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Api.CustomMiddleware/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Api.CustomMiddleware/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Api.CustomService/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Api.CustomService/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Api.CustomService/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Api.CustomService/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Api.Data.Audit/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Api.Data.Audit/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Api.Data.Audit/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Api.Data.Audit/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Api.Data.EntityEvents/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Api.Data.EntityEvents/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Api.Data.EntityEvents/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Api.Data.EntityEvents/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Api.Data.Identity.Auth.Jwt/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Api.Data.Identity.Auth.Jwt/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Api.Data.Identity/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Api.Data.Identity/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Api.Data.Identity/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Api.Data.Identity/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Api.Data.InMemory/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Api.Data.InMemory/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Api.Data.InMemory/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Api.Data.InMemory/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Api.Data.LazyLoading/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Api.Data.LazyLoading/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Api.Data.LazyLoading/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Api.Data.LazyLoading/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Api.Data.MySql.Collation/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Api.Data.MySql.Collation/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Api.Data.MySql.Collation/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Api.Data.MySql.Collation/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Api.Data.MySql.Mappings/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Api.Data.MySql.Mappings/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Api.Data.MySql.Mappings/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Api.Data.MySql.Mappings/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Api.Data.MySql.Spatial/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Api.Data.MySql.Spatial/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Api.Data.MySql.Spatial/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Api.Data.MySql.Spatial/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Api.Data.MySql.StoredProcedures/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Api.Data.MySql.StoredProcedures/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Api.Data.MySql.Views/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Api.Data.MySql.Views/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Api.Data.MySql.Views/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Api.Data.MySql.Views/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Api.Data.MySql/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Api.Data.MySql/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Api.Data.MySql/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Api.Data.MySql/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Api.Data.PostgreSQL.Spatial/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Api.Data.PostgreSQL.Spatial/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Api.Data.PostgreSQL/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Api.Data.PostgreSQL/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Api.Data.PostgreSQL/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Api.Data.PostgreSQL/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Api.Data.Repository.AutoSave/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Api.Data.Repository.AutoSave/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Api.Data.Repository.AutoSave/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Api.Data.Repository.AutoSave/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Api.Data.Repository.Includes/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Api.Data.Repository.Includes/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Api.Data.Repository.Includes/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Api.Data.Repository.Includes/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Api.Data.SoftDelete/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Api.Data.SoftDelete/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Api.Data.SoftDelete/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Api.Data.SoftDelete/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Api.Data.SqLite/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Api.Data.SqLite/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Api.Data.SqLite/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Api.Data.SqLite/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Api.Data.SqlServer.Spatial/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Api.Data.SqlServer.Spatial/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Api.Data.SqlServer.Spatial/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Api.Data.SqlServer.Spatial/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Api.Data.SqlServer/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Api.Data.SqlServer/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Api.Data.SqlServer/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Api.Data.SqlServer/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Api.Data.Triggers/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Api.Data.Triggers/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Api.Data.Triggers/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Api.Data.Triggers/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Api.Documentation.Csp/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Api.Documentation.Csp/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Api.Documentation.Csp/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Api.Documentation.Csp/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Api.Documentation/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Api.Documentation/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Api.Documentation/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Api.Documentation/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Api.ErrorHandling/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Api.ErrorHandling/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Api.ErrorHandling/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Api.ErrorHandling/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Api.Eventing.RabbitMq/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Api.Eventing.RabbitMq/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Api.Eventing.RabbitMq/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Api.Eventing.RabbitMq/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Api.HealthChecks/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Api.HealthChecks/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Api.HealthChecks/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Api.HealthChecks/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Api.Hosting.Http/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Api.Hosting.Http/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Api.Hosting.Http/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Api.Hosting.Http/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Api.Hosting.Https/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Api.Hosting.Https/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Api.Hosting.Https/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Api.Hosting.Https/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Api.Hosting.MultipartLimits/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Api.Hosting.MultipartLimits/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Api.Hosting.MultipartLimits/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Api.Hosting.MultipartLimits/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Api.Localization/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Api.Localization/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Api.Localization/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Api.Localization/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Api.Logging.Log4Net/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Api.Logging.Log4Net/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Api.Logging.Log4Net/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Api.Logging.Log4Net/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Api.Logging.Microsoft/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Api.Logging.Microsoft/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Api.Logging.Microsoft/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Api.Logging.Microsoft/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Api.Logging.NLog/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Api.Logging.NLog/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Api.Logging.NLog/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Api.Logging.NLog/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Api.Logging.Serilog/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Api.Logging.Serilog/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Api.Logging.Serilog/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Api.Logging.Serilog/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Api.Metrics/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Api.Metrics/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Api.Metrics/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Api.Metrics/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Api.MultipartJson/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Api.MultipartJson/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Api.MultipartJson/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Api.MultipartJson/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Api.PolicyHeaders.Cors/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Api.PolicyHeaders.Cors/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Api.PolicyHeaders.Cors/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Api.PolicyHeaders.Cors/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Api.PolicyHeaders.FrameOptions/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Api.PolicyHeaders.FrameOptions/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Api.PolicyHeaders.Hsts/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Api.PolicyHeaders.Hsts/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Api.PolicyHeaders.Hsts/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Api.PolicyHeaders.Hsts/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Api.PolicyHeaders.Robots/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Api.PolicyHeaders.Robots/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Api.PolicyHeaders.Robots/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Api.PolicyHeaders.Robots/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Api.PolicyHeaders.XssProtection/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Api.PolicyHeaders.XssProtection/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Api.RequestTracing/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Api.RequestTracing/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Api.RequestTracing/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Api.RequestTracing/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Api.ResponseCache/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Api.ResponseCache/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Api.ResponseCache/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Api.ResponseCache/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Api.ResponseCompression/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Api.ResponseCompression/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Api.ResponseCompression/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Api.ResponseCompression/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Api.Session/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Api.Session/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Api.Session/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Api.Session/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Api.StartupTasks/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Api.StartupTasks/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Api.StartupTasks/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Api.StartupTasks/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Api.StaticFiles/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Api.StaticFiles/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Api.StaticFiles/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Api.StaticFiles/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Api.Storage.Azure/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Api.Storage.Azure/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Api.Storage.Azure/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Api.Storage.Azure/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Api.Storage.Local/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Api.Storage.Local/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Api.Storage.Local/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Api.Storage.Local/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Api.TimeZone/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Api.TimeZone/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Api.TimeZone/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Api.TimeZone/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Api.Versioning/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Api.Versioning/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Api.Versioning/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Api.Versioning/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Api.VirusScan/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Api.VirusScan/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Api.VirusScan/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Api.VirusScan/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Api._Blank/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Api._Blank/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Api._Blank/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Api._Blank/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Console.CustomConfigSection/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Console.CustomConfigSection/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Console.CustomConfigSection/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Console.CustomConfigSection/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Console.CustomService/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Console.CustomService/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Console.CustomService/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Console.CustomService/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Console.Data.InMemory/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Console.Data.InMemory/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Console.Data.InMemory/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Console.Data.InMemory/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Console.Data.MySql/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Console.Data.MySql/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Console.Data.MySql/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Console.Data.MySql/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Console.Data.PostgreSQL/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Console.Data.PostgreSQL/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Console.Data.PostgreSQL/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Console.Data.PostgreSQL/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Console.Data.SqLite/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Console.Data.SqLite/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Console.Data.SqLite/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Console.Data.SqLite/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Console.Data.SqlServer/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Console.Data.SqlServer/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Console.Data.SqlServer/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Console.Data.SqlServer/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Console.Eventing.RabbitMq/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Console.Eventing.RabbitMq/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Console.Eventing.RabbitMq/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Console.Eventing.RabbitMq/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Console.ExceptionHandling/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Console.ExceptionHandling/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Console.ExceptionHandling/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Console.ExceptionHandling/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Console.Localization/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Console.Localization/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Console.Localization/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Console.Localization/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Console.Logging.Log4Net/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Console.Logging.Log4Net/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Console.Logging.Log4Net/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Console.Logging.Log4Net/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Console.Logging.Microsoft/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Console.Logging.Microsoft/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Console.Logging.Microsoft/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Console.Logging.Microsoft/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Console.Logging.NLog/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Console.Logging.NLog/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Console.Logging.NLog/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Console.Logging.NLog/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Console.Logging.Serilog/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Console.Logging.Serilog/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Console.Logging.Serilog/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Console.Logging.Serilog/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Console.StartupTasks/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Console.StartupTasks/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Console.StartupTasks/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Console.StartupTasks/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Console.Storage.Azure/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Console.Storage.Azure/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Console.Storage.Azure/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Console.Storage.Azure/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Console.Storage.Local/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Console.Storage.Local/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Console.Storage.Local/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Console.Storage.Local/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Console.Workers/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Console.Workers/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Console.Workers/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Console.Workers/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Console._Blank/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Console._Blank/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Console._Blank/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Console._Blank/.github/prompts/nano-remove-authentication-external-custom.prompt.md create mode 100644 Web._Blank/.claude/skills/nano-add-authentication-external-custom/SKILL.md create mode 100644 Web._Blank/.claude/skills/nano-remove-authentication-external-custom/SKILL.md create mode 100644 Web._Blank/.github/prompts/nano-add-authentication-external-custom.prompt.md create mode 100644 Web._Blank/.github/prompts/nano-remove-authentication-external-custom.prompt.md diff --git a/Api.ApiClients.Audit/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.ApiClients.Audit/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Api.ApiClients.Audit/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.ApiClients.Audit/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.ApiClients.Audit/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Api.ApiClients.Audit/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.ApiClients.Audit/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only 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 d9f45740..46b8a48c 100644 --- a/Api.ApiClients.Audit/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.ApiClients.Audit/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.ApiClients.Audit/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Api.ApiClients.Audit/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Api.ApiClients.Audit/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Api.ApiClients.Audit/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.ApiClients.Audit/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Api.ApiClients.Audit/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.ApiClients.Audit/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.ApiClients.Audit/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Api.ApiClients.Audit/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.ApiClients.Audit/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only 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 f8345ca6..71b15597 100644 --- a/Api.ApiClients.Audit/.github/prompts/nano-add-entity.prompt.md +++ b/Api.ApiClients.Audit/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.ApiClients.Audit/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Api.ApiClients.Audit/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Api.ApiClients.Audit/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Api.ApiClients.Audit/AGENTS.md b/Api.ApiClients.Audit/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Api.ApiClients.Audit/AGENTS.md +++ b/Api.ApiClients.Audit/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Api.ApiClients.Entity/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.ApiClients.Entity/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Api.ApiClients.Entity/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.ApiClients.Entity/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.ApiClients.Entity/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Api.ApiClients.Entity/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.ApiClients.Entity/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only 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 d9f45740..46b8a48c 100644 --- a/Api.ApiClients.Entity/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.ApiClients.Entity/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.ApiClients.Entity/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Api.ApiClients.Entity/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Api.ApiClients.Entity/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Api.ApiClients.Entity/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.ApiClients.Entity/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Api.ApiClients.Entity/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.ApiClients.Entity/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.ApiClients.Entity/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Api.ApiClients.Entity/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.ApiClients.Entity/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only 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 f8345ca6..71b15597 100644 --- a/Api.ApiClients.Entity/.github/prompts/nano-add-entity.prompt.md +++ b/Api.ApiClients.Entity/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.ApiClients.Entity/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Api.ApiClients.Entity/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Api.ApiClients.Entity/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Api.ApiClients.Entity/AGENTS.md b/Api.ApiClients.Entity/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Api.ApiClients.Entity/AGENTS.md +++ b/Api.ApiClients.Entity/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Api.ApiClients.RootLogIn/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.ApiClients.RootLogIn/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Api.ApiClients.RootLogIn/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.ApiClients.RootLogIn/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.ApiClients.RootLogIn/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Api.ApiClients.RootLogIn/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.ApiClients.RootLogIn/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only 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 d9f45740..46b8a48c 100644 --- a/Api.ApiClients.RootLogIn/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.ApiClients.RootLogIn/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.ApiClients.RootLogIn/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Api.ApiClients.RootLogIn/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Api.ApiClients.RootLogIn/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Api.ApiClients.RootLogIn/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.ApiClients.RootLogIn/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Api.ApiClients.RootLogIn/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only 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 f8345ca6..71b15597 100644 --- a/Api.ApiClients.RootLogIn/.github/prompts/nano-add-entity.prompt.md +++ b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.ApiClients.RootLogIn/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Api.ApiClients.RootLogIn/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Api.ApiClients.RootLogIn/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Api.ApiClients.RootLogIn/AGENTS.md b/Api.ApiClients.RootLogIn/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Api.ApiClients.RootLogIn/AGENTS.md +++ b/Api.ApiClients.RootLogIn/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Api.ApiClients/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.ApiClients/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Api.ApiClients/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.ApiClients/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.ApiClients/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Api.ApiClients/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.ApiClients/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only diff --git a/Api.ApiClients/.claude/skills/nano-add-entity/SKILL.md b/Api.ApiClients/.claude/skills/nano-add-entity/SKILL.md index d9f45740..46b8a48c 100644 --- a/Api.ApiClients/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.ApiClients/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.ApiClients/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Api.ApiClients/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Api.ApiClients/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Api.ApiClients/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.ApiClients/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Api.ApiClients/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.ApiClients/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.ApiClients/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Api.ApiClients/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.ApiClients/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only diff --git a/Api.ApiClients/.github/prompts/nano-add-entity.prompt.md b/Api.ApiClients/.github/prompts/nano-add-entity.prompt.md index f8345ca6..71b15597 100644 --- a/Api.ApiClients/.github/prompts/nano-add-entity.prompt.md +++ b/Api.ApiClients/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.ApiClients/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Api.ApiClients/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Api.ApiClients/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Api.ApiClients/AGENTS.md b/Api.ApiClients/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Api.ApiClients/AGENTS.md +++ b/Api.ApiClients/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Api.Auth.External.Custom/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Auth.External.Custom/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Api.Auth.External.Custom/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Auth.External.Custom/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Auth.External.Custom/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Api.Auth.External.Custom/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Auth.External.Custom/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only 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 d9f45740..46b8a48c 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 @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Auth.External.Custom/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Api.Auth.External.Custom/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Api.Auth.External.Custom/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Api.Auth.External.Custom/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Auth.External.Custom/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Api.Auth.External.Custom/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Auth.External.Custom/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Auth.External.Custom/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Api.Auth.External.Custom/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Auth.External.Custom/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only 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 f8345ca6..71b15597 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 @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Auth.External.Custom/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Api.Auth.External.Custom/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Api.Auth.External.Custom/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Api.Auth.External.Custom/AGENTS.md b/Api.Auth.External.Custom/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Api.Auth.External.Custom/AGENTS.md +++ b/Api.Auth.External.Custom/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Api.Auth.External.Microsoft/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Auth.External.Microsoft/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Api.Auth.External.Microsoft/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Auth.External.Microsoft/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Auth.External.Microsoft/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Api.Auth.External.Microsoft/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Auth.External.Microsoft/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only 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 d9f45740..46b8a48c 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 @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Auth.External.Microsoft/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Api.Auth.External.Microsoft/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Api.Auth.External.Microsoft/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Api.Auth.External.Microsoft/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Auth.External.Microsoft/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Api.Auth.External.Microsoft/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Auth.External.Microsoft/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Auth.External.Microsoft/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Api.Auth.External.Microsoft/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Auth.External.Microsoft/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only 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 f8345ca6..71b15597 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 @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Auth.External.Microsoft/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Api.Auth.External.Microsoft/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Api.Auth.External.Microsoft/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Api.Auth.External.Microsoft/AGENTS.md b/Api.Auth.External.Microsoft/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Api.Auth.External.Microsoft/AGENTS.md +++ b/Api.Auth.External.Microsoft/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Api.Auth.RootLogin/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Auth.RootLogin/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Api.Auth.RootLogin/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Auth.RootLogin/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Auth.RootLogin/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Api.Auth.RootLogin/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Auth.RootLogin/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only 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 d9f45740..46b8a48c 100644 --- a/Api.Auth.RootLogin/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Auth.RootLogin/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Auth.RootLogin/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Api.Auth.RootLogin/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Api.Auth.RootLogin/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Api.Auth.RootLogin/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Auth.RootLogin/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Api.Auth.RootLogin/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Auth.RootLogin/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Auth.RootLogin/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Api.Auth.RootLogin/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Auth.RootLogin/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only 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 f8345ca6..71b15597 100644 --- a/Api.Auth.RootLogin/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Auth.RootLogin/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Auth.RootLogin/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Api.Auth.RootLogin/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Api.Auth.RootLogin/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Api.Auth.RootLogin/AGENTS.md b/Api.Auth.RootLogin/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Api.Auth.RootLogin/AGENTS.md +++ b/Api.Auth.RootLogin/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Api.Authorization/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Authorization/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Api.Authorization/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Authorization/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Authorization/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Api.Authorization/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Authorization/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only diff --git a/Api.Authorization/.claude/skills/nano-add-entity/SKILL.md b/Api.Authorization/.claude/skills/nano-add-entity/SKILL.md index d9f45740..46b8a48c 100644 --- a/Api.Authorization/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Authorization/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Authorization/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Api.Authorization/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Api.Authorization/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Api.Authorization/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Authorization/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Api.Authorization/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Authorization/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Authorization/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Api.Authorization/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Authorization/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only diff --git a/Api.Authorization/.github/prompts/nano-add-entity.prompt.md b/Api.Authorization/.github/prompts/nano-add-entity.prompt.md index f8345ca6..71b15597 100644 --- a/Api.Authorization/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Authorization/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Authorization/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Api.Authorization/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Api.Authorization/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Api.Authorization/AGENTS.md b/Api.Authorization/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Api.Authorization/AGENTS.md +++ b/Api.Authorization/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Api.ContentNegotiation/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.ContentNegotiation/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Api.ContentNegotiation/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.ContentNegotiation/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.ContentNegotiation/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Api.ContentNegotiation/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.ContentNegotiation/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only diff --git a/Api.ContentNegotiation/.claude/skills/nano-add-entity/SKILL.md b/Api.ContentNegotiation/.claude/skills/nano-add-entity/SKILL.md index d9f45740..46b8a48c 100644 --- a/Api.ContentNegotiation/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.ContentNegotiation/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.ContentNegotiation/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Api.ContentNegotiation/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Api.ContentNegotiation/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Api.ContentNegotiation/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.ContentNegotiation/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Api.ContentNegotiation/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.ContentNegotiation/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.ContentNegotiation/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Api.ContentNegotiation/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.ContentNegotiation/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only diff --git a/Api.ContentNegotiation/.github/prompts/nano-add-entity.prompt.md b/Api.ContentNegotiation/.github/prompts/nano-add-entity.prompt.md index f8345ca6..71b15597 100644 --- a/Api.ContentNegotiation/.github/prompts/nano-add-entity.prompt.md +++ b/Api.ContentNegotiation/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.ContentNegotiation/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Api.ContentNegotiation/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Api.ContentNegotiation/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Api.ContentNegotiation/AGENTS.md b/Api.ContentNegotiation/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Api.ContentNegotiation/AGENTS.md +++ b/Api.ContentNegotiation/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Api.Cookies/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Cookies/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Api.Cookies/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Cookies/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Cookies/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Api.Cookies/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Cookies/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only diff --git a/Api.Cookies/.claude/skills/nano-add-entity/SKILL.md b/Api.Cookies/.claude/skills/nano-add-entity/SKILL.md index d9f45740..46b8a48c 100644 --- a/Api.Cookies/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Cookies/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Cookies/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Api.Cookies/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Api.Cookies/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Api.Cookies/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Cookies/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Api.Cookies/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Cookies/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Cookies/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Api.Cookies/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Cookies/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only diff --git a/Api.Cookies/.github/prompts/nano-add-entity.prompt.md b/Api.Cookies/.github/prompts/nano-add-entity.prompt.md index f8345ca6..71b15597 100644 --- a/Api.Cookies/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Cookies/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Cookies/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Api.Cookies/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Api.Cookies/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Api.Cookies/AGENTS.md b/Api.Cookies/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Api.Cookies/AGENTS.md +++ b/Api.Cookies/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Api.CustomConfigSection/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.CustomConfigSection/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Api.CustomConfigSection/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.CustomConfigSection/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.CustomConfigSection/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Api.CustomConfigSection/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.CustomConfigSection/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only diff --git a/Api.CustomConfigSection/.claude/skills/nano-add-entity/SKILL.md b/Api.CustomConfigSection/.claude/skills/nano-add-entity/SKILL.md index d9f45740..46b8a48c 100644 --- a/Api.CustomConfigSection/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.CustomConfigSection/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.CustomConfigSection/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Api.CustomConfigSection/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Api.CustomConfigSection/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Api.CustomConfigSection/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.CustomConfigSection/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Api.CustomConfigSection/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.CustomConfigSection/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.CustomConfigSection/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Api.CustomConfigSection/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.CustomConfigSection/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only diff --git a/Api.CustomConfigSection/.github/prompts/nano-add-entity.prompt.md b/Api.CustomConfigSection/.github/prompts/nano-add-entity.prompt.md index f8345ca6..71b15597 100644 --- a/Api.CustomConfigSection/.github/prompts/nano-add-entity.prompt.md +++ b/Api.CustomConfigSection/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.CustomConfigSection/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Api.CustomConfigSection/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Api.CustomConfigSection/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Api.CustomConfigSection/AGENTS.md b/Api.CustomConfigSection/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Api.CustomConfigSection/AGENTS.md +++ b/Api.CustomConfigSection/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Api.CustomMiddleware/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.CustomMiddleware/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Api.CustomMiddleware/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.CustomMiddleware/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.CustomMiddleware/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Api.CustomMiddleware/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.CustomMiddleware/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only diff --git a/Api.CustomMiddleware/.claude/skills/nano-add-entity/SKILL.md b/Api.CustomMiddleware/.claude/skills/nano-add-entity/SKILL.md index d9f45740..46b8a48c 100644 --- a/Api.CustomMiddleware/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.CustomMiddleware/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.CustomMiddleware/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Api.CustomMiddleware/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Api.CustomMiddleware/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Api.CustomMiddleware/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.CustomMiddleware/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Api.CustomMiddleware/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.CustomMiddleware/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.CustomMiddleware/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Api.CustomMiddleware/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.CustomMiddleware/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only diff --git a/Api.CustomMiddleware/.github/prompts/nano-add-entity.prompt.md b/Api.CustomMiddleware/.github/prompts/nano-add-entity.prompt.md index f8345ca6..71b15597 100644 --- a/Api.CustomMiddleware/.github/prompts/nano-add-entity.prompt.md +++ b/Api.CustomMiddleware/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.CustomMiddleware/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Api.CustomMiddleware/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Api.CustomMiddleware/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Api.CustomMiddleware/AGENTS.md b/Api.CustomMiddleware/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Api.CustomMiddleware/AGENTS.md +++ b/Api.CustomMiddleware/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Api.CustomService/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.CustomService/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Api.CustomService/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.CustomService/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.CustomService/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Api.CustomService/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.CustomService/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only diff --git a/Api.CustomService/.claude/skills/nano-add-entity/SKILL.md b/Api.CustomService/.claude/skills/nano-add-entity/SKILL.md index d9f45740..46b8a48c 100644 --- a/Api.CustomService/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.CustomService/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.CustomService/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Api.CustomService/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Api.CustomService/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Api.CustomService/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.CustomService/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Api.CustomService/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.CustomService/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.CustomService/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Api.CustomService/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.CustomService/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only diff --git a/Api.CustomService/.github/prompts/nano-add-entity.prompt.md b/Api.CustomService/.github/prompts/nano-add-entity.prompt.md index f8345ca6..71b15597 100644 --- a/Api.CustomService/.github/prompts/nano-add-entity.prompt.md +++ b/Api.CustomService/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.CustomService/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Api.CustomService/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Api.CustomService/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Api.CustomService/AGENTS.md b/Api.CustomService/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Api.CustomService/AGENTS.md +++ b/Api.CustomService/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Api.Data.Audit/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Data.Audit/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Api.Data.Audit/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Data.Audit/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Data.Audit/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Api.Data.Audit/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Data.Audit/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only 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 d9f45740..46b8a48c 100644 --- a/Api.Data.Audit/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.Audit/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Data.Audit/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Api.Data.Audit/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Api.Data.Audit/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Api.Data.Audit/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Data.Audit/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Api.Data.Audit/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Data.Audit/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Data.Audit/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Api.Data.Audit/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Data.Audit/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only 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 f8345ca6..71b15597 100644 --- a/Api.Data.Audit/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.Audit/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Data.Audit/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Api.Data.Audit/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Api.Data.Audit/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Api.Data.Audit/AGENTS.md b/Api.Data.Audit/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Api.Data.Audit/AGENTS.md +++ b/Api.Data.Audit/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Api.Data.EntityEvents/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Data.EntityEvents/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Api.Data.EntityEvents/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Data.EntityEvents/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Data.EntityEvents/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Api.Data.EntityEvents/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Data.EntityEvents/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only 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 d9f45740..46b8a48c 100644 --- a/Api.Data.EntityEvents/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.EntityEvents/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Data.EntityEvents/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Api.Data.EntityEvents/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Api.Data.EntityEvents/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Api.Data.EntityEvents/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Data.EntityEvents/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Api.Data.EntityEvents/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Data.EntityEvents/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Data.EntityEvents/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Api.Data.EntityEvents/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Data.EntityEvents/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only 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 f8345ca6..71b15597 100644 --- a/Api.Data.EntityEvents/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.EntityEvents/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Data.EntityEvents/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Api.Data.EntityEvents/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Api.Data.EntityEvents/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Api.Data.EntityEvents/AGENTS.md b/Api.Data.EntityEvents/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Api.Data.EntityEvents/AGENTS.md +++ b/Api.Data.EntityEvents/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only 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 d9f45740..46b8a48c 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 @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only 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 f8345ca6..71b15597 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 @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Api.Data.Identity.Auth.ApiKey/AGENTS.md b/Api.Data.Identity.Auth.ApiKey/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Api.Data.Identity.Auth.ApiKey/AGENTS.md +++ b/Api.Data.Identity.Auth.ApiKey/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only 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 d9f45740..46b8a48c 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 @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only 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 f8345ca6..71b15597 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 @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Api.Data.Identity.Auth.External.Custom/AGENTS.md b/Api.Data.Identity.Auth.External.Custom/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Api.Data.Identity.Auth.External.Custom/AGENTS.md +++ b/Api.Data.Identity.Auth.External.Custom/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only 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 d9f45740..46b8a48c 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 @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only 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 f8345ca6..71b15597 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 @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Api.Data.Identity.Auth.Jwt/AGENTS.md b/Api.Data.Identity.Auth.Jwt/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Api.Data.Identity.Auth.Jwt/AGENTS.md +++ b/Api.Data.Identity.Auth.Jwt/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Api.Data.Identity/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Data.Identity/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Api.Data.Identity/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Data.Identity/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Data.Identity/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Api.Data.Identity/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Data.Identity/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only 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 d9f45740..46b8a48c 100644 --- a/Api.Data.Identity/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.Identity/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Data.Identity/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Api.Data.Identity/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Api.Data.Identity/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Api.Data.Identity/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Data.Identity/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Api.Data.Identity/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Data.Identity/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Data.Identity/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Api.Data.Identity/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Data.Identity/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only 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 f8345ca6..71b15597 100644 --- a/Api.Data.Identity/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.Identity/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Data.Identity/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Api.Data.Identity/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Api.Data.Identity/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Api.Data.Identity/AGENTS.md b/Api.Data.Identity/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Api.Data.Identity/AGENTS.md +++ b/Api.Data.Identity/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Api.Data.InMemory/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Data.InMemory/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Api.Data.InMemory/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Data.InMemory/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Data.InMemory/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Api.Data.InMemory/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Data.InMemory/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only 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 d9f45740..46b8a48c 100644 --- a/Api.Data.InMemory/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.InMemory/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Data.InMemory/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Api.Data.InMemory/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Api.Data.InMemory/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Api.Data.InMemory/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Data.InMemory/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Api.Data.InMemory/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Data.InMemory/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Data.InMemory/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Api.Data.InMemory/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Data.InMemory/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only 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 f8345ca6..71b15597 100644 --- a/Api.Data.InMemory/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.InMemory/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Data.InMemory/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Api.Data.InMemory/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Api.Data.InMemory/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Api.Data.InMemory/AGENTS.md b/Api.Data.InMemory/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Api.Data.InMemory/AGENTS.md +++ b/Api.Data.InMemory/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Api.Data.LazyLoading/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Data.LazyLoading/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Api.Data.LazyLoading/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Data.LazyLoading/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Data.LazyLoading/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Api.Data.LazyLoading/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Data.LazyLoading/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only 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 d9f45740..46b8a48c 100644 --- a/Api.Data.LazyLoading/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.LazyLoading/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Data.LazyLoading/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Api.Data.LazyLoading/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Api.Data.LazyLoading/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Api.Data.LazyLoading/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Data.LazyLoading/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Api.Data.LazyLoading/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Data.LazyLoading/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Data.LazyLoading/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Api.Data.LazyLoading/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Data.LazyLoading/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only 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 f8345ca6..71b15597 100644 --- a/Api.Data.LazyLoading/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.LazyLoading/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Data.LazyLoading/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Api.Data.LazyLoading/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Api.Data.LazyLoading/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Api.Data.LazyLoading/AGENTS.md b/Api.Data.LazyLoading/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Api.Data.LazyLoading/AGENTS.md +++ b/Api.Data.LazyLoading/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Api.Data.MySql.Collation/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Data.MySql.Collation/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Api.Data.MySql.Collation/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Data.MySql.Collation/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Data.MySql.Collation/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Api.Data.MySql.Collation/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Data.MySql.Collation/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only 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 d9f45740..46b8a48c 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 @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Data.MySql.Collation/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Api.Data.MySql.Collation/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Api.Data.MySql.Collation/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Api.Data.MySql.Collation/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Data.MySql.Collation/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Api.Data.MySql.Collation/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Data.MySql.Collation/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Data.MySql.Collation/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Api.Data.MySql.Collation/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Data.MySql.Collation/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only 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 f8345ca6..71b15597 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 @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Data.MySql.Collation/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Api.Data.MySql.Collation/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Api.Data.MySql.Collation/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Api.Data.MySql.Collation/AGENTS.md b/Api.Data.MySql.Collation/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Api.Data.MySql.Collation/AGENTS.md +++ b/Api.Data.MySql.Collation/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Api.Data.MySql.Mappings/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Data.MySql.Mappings/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Api.Data.MySql.Mappings/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Data.MySql.Mappings/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Data.MySql.Mappings/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Api.Data.MySql.Mappings/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Data.MySql.Mappings/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only 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 d9f45740..46b8a48c 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 @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Data.MySql.Mappings/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Api.Data.MySql.Mappings/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Api.Data.MySql.Mappings/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Api.Data.MySql.Mappings/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Data.MySql.Mappings/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Api.Data.MySql.Mappings/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Data.MySql.Mappings/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Data.MySql.Mappings/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Api.Data.MySql.Mappings/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Data.MySql.Mappings/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only 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 f8345ca6..71b15597 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 @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Data.MySql.Mappings/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Api.Data.MySql.Mappings/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Api.Data.MySql.Mappings/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Api.Data.MySql.Mappings/AGENTS.md b/Api.Data.MySql.Mappings/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Api.Data.MySql.Mappings/AGENTS.md +++ b/Api.Data.MySql.Mappings/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Api.Data.MySql.Spatial/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Data.MySql.Spatial/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Api.Data.MySql.Spatial/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Data.MySql.Spatial/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Data.MySql.Spatial/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Api.Data.MySql.Spatial/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Data.MySql.Spatial/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only 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 d9f45740..46b8a48c 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 @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Data.MySql.Spatial/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Api.Data.MySql.Spatial/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Api.Data.MySql.Spatial/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Api.Data.MySql.Spatial/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Data.MySql.Spatial/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Api.Data.MySql.Spatial/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Data.MySql.Spatial/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Data.MySql.Spatial/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Api.Data.MySql.Spatial/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Data.MySql.Spatial/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only 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 f8345ca6..71b15597 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 @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Data.MySql.Spatial/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Api.Data.MySql.Spatial/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Api.Data.MySql.Spatial/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Api.Data.MySql.Spatial/AGENTS.md b/Api.Data.MySql.Spatial/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Api.Data.MySql.Spatial/AGENTS.md +++ b/Api.Data.MySql.Spatial/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only 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 d9f45740..46b8a48c 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 @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Data.MySql.StoredProcedures/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Api.Data.MySql.StoredProcedures/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Api.Data.MySql.StoredProcedures/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only 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 f8345ca6..71b15597 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 @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Data.MySql.StoredProcedures/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Api.Data.MySql.StoredProcedures/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Api.Data.MySql.StoredProcedures/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Api.Data.MySql.StoredProcedures/AGENTS.md b/Api.Data.MySql.StoredProcedures/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Api.Data.MySql.StoredProcedures/AGENTS.md +++ b/Api.Data.MySql.StoredProcedures/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Api.Data.MySql.Views/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Data.MySql.Views/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Api.Data.MySql.Views/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Data.MySql.Views/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Data.MySql.Views/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Api.Data.MySql.Views/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Data.MySql.Views/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only 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 d9f45740..46b8a48c 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 @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Data.MySql.Views/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Api.Data.MySql.Views/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Api.Data.MySql.Views/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Api.Data.MySql.Views/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Data.MySql.Views/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Api.Data.MySql.Views/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Data.MySql.Views/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Data.MySql.Views/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Api.Data.MySql.Views/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Data.MySql.Views/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only 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 f8345ca6..71b15597 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 @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Data.MySql.Views/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Api.Data.MySql.Views/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Api.Data.MySql.Views/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Api.Data.MySql.Views/AGENTS.md b/Api.Data.MySql.Views/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Api.Data.MySql.Views/AGENTS.md +++ b/Api.Data.MySql.Views/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Api.Data.MySql/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Data.MySql/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Api.Data.MySql/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Data.MySql/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Data.MySql/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Api.Data.MySql/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Data.MySql/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only 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 d9f45740..46b8a48c 100644 --- a/Api.Data.MySql/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.MySql/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Data.MySql/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Api.Data.MySql/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Api.Data.MySql/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Api.Data.MySql/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Data.MySql/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Api.Data.MySql/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Data.MySql/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Data.MySql/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Api.Data.MySql/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Data.MySql/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only 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 f8345ca6..71b15597 100644 --- a/Api.Data.MySql/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.MySql/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Data.MySql/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Api.Data.MySql/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Api.Data.MySql/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Api.Data.MySql/AGENTS.md b/Api.Data.MySql/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Api.Data.MySql/AGENTS.md +++ b/Api.Data.MySql/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only 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 d9f45740..46b8a48c 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 @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only 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 f8345ca6..71b15597 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 @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Api.Data.PostgreSQL.Spatial/AGENTS.md b/Api.Data.PostgreSQL.Spatial/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Api.Data.PostgreSQL.Spatial/AGENTS.md +++ b/Api.Data.PostgreSQL.Spatial/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Api.Data.PostgreSQL/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Data.PostgreSQL/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Api.Data.PostgreSQL/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Data.PostgreSQL/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Data.PostgreSQL/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Api.Data.PostgreSQL/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Data.PostgreSQL/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only 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 d9f45740..46b8a48c 100644 --- a/Api.Data.PostgreSQL/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.PostgreSQL/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Data.PostgreSQL/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Api.Data.PostgreSQL/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Api.Data.PostgreSQL/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Api.Data.PostgreSQL/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Data.PostgreSQL/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Api.Data.PostgreSQL/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Data.PostgreSQL/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Data.PostgreSQL/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Api.Data.PostgreSQL/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Data.PostgreSQL/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only 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 f8345ca6..71b15597 100644 --- a/Api.Data.PostgreSQL/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.PostgreSQL/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Data.PostgreSQL/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Api.Data.PostgreSQL/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Api.Data.PostgreSQL/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Api.Data.PostgreSQL/AGENTS.md b/Api.Data.PostgreSQL/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Api.Data.PostgreSQL/AGENTS.md +++ b/Api.Data.PostgreSQL/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Api.Data.Repository.AutoSave/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Data.Repository.AutoSave/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Api.Data.Repository.AutoSave/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Data.Repository.AutoSave/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Data.Repository.AutoSave/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Api.Data.Repository.AutoSave/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Data.Repository.AutoSave/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only 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 d9f45740..46b8a48c 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 @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Data.Repository.AutoSave/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Api.Data.Repository.AutoSave/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Api.Data.Repository.AutoSave/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Api.Data.Repository.AutoSave/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Data.Repository.AutoSave/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Api.Data.Repository.AutoSave/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Data.Repository.AutoSave/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Data.Repository.AutoSave/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Api.Data.Repository.AutoSave/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Data.Repository.AutoSave/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only 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 f8345ca6..71b15597 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 @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Data.Repository.AutoSave/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Api.Data.Repository.AutoSave/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Api.Data.Repository.AutoSave/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Api.Data.Repository.AutoSave/AGENTS.md b/Api.Data.Repository.AutoSave/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Api.Data.Repository.AutoSave/AGENTS.md +++ b/Api.Data.Repository.AutoSave/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Api.Data.Repository.Includes/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Data.Repository.Includes/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Api.Data.Repository.Includes/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Data.Repository.Includes/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Data.Repository.Includes/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Api.Data.Repository.Includes/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Data.Repository.Includes/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only 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 d9f45740..46b8a48c 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 @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Data.Repository.Includes/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Api.Data.Repository.Includes/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Api.Data.Repository.Includes/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Api.Data.Repository.Includes/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Data.Repository.Includes/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Api.Data.Repository.Includes/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Data.Repository.Includes/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Data.Repository.Includes/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Api.Data.Repository.Includes/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Data.Repository.Includes/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only 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 f8345ca6..71b15597 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 @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Data.Repository.Includes/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Api.Data.Repository.Includes/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Api.Data.Repository.Includes/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Api.Data.Repository.Includes/AGENTS.md b/Api.Data.Repository.Includes/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Api.Data.Repository.Includes/AGENTS.md +++ b/Api.Data.Repository.Includes/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Api.Data.SoftDelete/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Data.SoftDelete/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Api.Data.SoftDelete/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Data.SoftDelete/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Data.SoftDelete/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Api.Data.SoftDelete/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Data.SoftDelete/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only 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 d9f45740..46b8a48c 100644 --- a/Api.Data.SoftDelete/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.SoftDelete/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Data.SoftDelete/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Api.Data.SoftDelete/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Api.Data.SoftDelete/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Api.Data.SoftDelete/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Data.SoftDelete/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Api.Data.SoftDelete/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Data.SoftDelete/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Data.SoftDelete/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Api.Data.SoftDelete/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Data.SoftDelete/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only 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 f8345ca6..71b15597 100644 --- a/Api.Data.SoftDelete/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.SoftDelete/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Data.SoftDelete/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Api.Data.SoftDelete/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Api.Data.SoftDelete/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Api.Data.SoftDelete/AGENTS.md b/Api.Data.SoftDelete/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Api.Data.SoftDelete/AGENTS.md +++ b/Api.Data.SoftDelete/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Api.Data.SqLite/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Data.SqLite/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Api.Data.SqLite/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Data.SqLite/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Data.SqLite/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Api.Data.SqLite/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Data.SqLite/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only 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 d9f45740..46b8a48c 100644 --- a/Api.Data.SqLite/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.SqLite/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Data.SqLite/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Api.Data.SqLite/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Api.Data.SqLite/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Api.Data.SqLite/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Data.SqLite/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Api.Data.SqLite/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Data.SqLite/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Data.SqLite/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Api.Data.SqLite/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Data.SqLite/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only 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 f8345ca6..71b15597 100644 --- a/Api.Data.SqLite/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.SqLite/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Data.SqLite/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Api.Data.SqLite/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Api.Data.SqLite/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Api.Data.SqLite/AGENTS.md b/Api.Data.SqLite/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Api.Data.SqLite/AGENTS.md +++ b/Api.Data.SqLite/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only 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 d9f45740..46b8a48c 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 @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Data.SqlServer.Spatial/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Api.Data.SqlServer.Spatial/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Api.Data.SqlServer.Spatial/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only 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 f8345ca6..71b15597 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 @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Data.SqlServer.Spatial/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Api.Data.SqlServer.Spatial/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Api.Data.SqlServer.Spatial/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Api.Data.SqlServer.Spatial/AGENTS.md b/Api.Data.SqlServer.Spatial/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Api.Data.SqlServer.Spatial/AGENTS.md +++ b/Api.Data.SqlServer.Spatial/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Api.Data.SqlServer/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Data.SqlServer/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Api.Data.SqlServer/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Data.SqlServer/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Data.SqlServer/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Api.Data.SqlServer/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Data.SqlServer/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only 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 d9f45740..46b8a48c 100644 --- a/Api.Data.SqlServer/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.SqlServer/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Data.SqlServer/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Api.Data.SqlServer/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Api.Data.SqlServer/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Api.Data.SqlServer/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Data.SqlServer/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Api.Data.SqlServer/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Data.SqlServer/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Data.SqlServer/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Api.Data.SqlServer/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Data.SqlServer/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only 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 f8345ca6..71b15597 100644 --- a/Api.Data.SqlServer/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.SqlServer/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Data.SqlServer/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Api.Data.SqlServer/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Api.Data.SqlServer/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Api.Data.SqlServer/AGENTS.md b/Api.Data.SqlServer/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Api.Data.SqlServer/AGENTS.md +++ b/Api.Data.SqlServer/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Api.Data.Triggers/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Data.Triggers/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Api.Data.Triggers/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Data.Triggers/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Data.Triggers/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Api.Data.Triggers/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Data.Triggers/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only 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 d9f45740..46b8a48c 100644 --- a/Api.Data.Triggers/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.Triggers/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Data.Triggers/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Api.Data.Triggers/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Api.Data.Triggers/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Api.Data.Triggers/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Data.Triggers/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Api.Data.Triggers/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Data.Triggers/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Data.Triggers/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Api.Data.Triggers/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Data.Triggers/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only 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 f8345ca6..71b15597 100644 --- a/Api.Data.Triggers/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.Triggers/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Data.Triggers/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Api.Data.Triggers/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Api.Data.Triggers/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Api.Data.Triggers/AGENTS.md b/Api.Data.Triggers/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Api.Data.Triggers/AGENTS.md +++ b/Api.Data.Triggers/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Api.Documentation.Csp/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Documentation.Csp/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Api.Documentation.Csp/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Documentation.Csp/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Documentation.Csp/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Api.Documentation.Csp/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Documentation.Csp/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only 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 d9f45740..46b8a48c 100644 --- a/Api.Documentation.Csp/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Documentation.Csp/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Documentation.Csp/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Api.Documentation.Csp/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Api.Documentation.Csp/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Api.Documentation.Csp/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Documentation.Csp/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Api.Documentation.Csp/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Documentation.Csp/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Documentation.Csp/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Api.Documentation.Csp/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Documentation.Csp/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only 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 f8345ca6..71b15597 100644 --- a/Api.Documentation.Csp/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Documentation.Csp/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Documentation.Csp/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Api.Documentation.Csp/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Api.Documentation.Csp/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Api.Documentation.Csp/AGENTS.md b/Api.Documentation.Csp/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Api.Documentation.Csp/AGENTS.md +++ b/Api.Documentation.Csp/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Api.Documentation/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Documentation/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Api.Documentation/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Documentation/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Documentation/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Api.Documentation/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Documentation/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only diff --git a/Api.Documentation/.claude/skills/nano-add-entity/SKILL.md b/Api.Documentation/.claude/skills/nano-add-entity/SKILL.md index d9f45740..46b8a48c 100644 --- a/Api.Documentation/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Documentation/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Documentation/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Api.Documentation/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Api.Documentation/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Api.Documentation/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Documentation/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Api.Documentation/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Documentation/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Documentation/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Api.Documentation/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Documentation/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only diff --git a/Api.Documentation/.github/prompts/nano-add-entity.prompt.md b/Api.Documentation/.github/prompts/nano-add-entity.prompt.md index f8345ca6..71b15597 100644 --- a/Api.Documentation/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Documentation/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Documentation/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Api.Documentation/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Api.Documentation/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Api.Documentation/AGENTS.md b/Api.Documentation/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Api.Documentation/AGENTS.md +++ b/Api.Documentation/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Api.ErrorHandling/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.ErrorHandling/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Api.ErrorHandling/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.ErrorHandling/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.ErrorHandling/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Api.ErrorHandling/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.ErrorHandling/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only diff --git a/Api.ErrorHandling/.claude/skills/nano-add-entity/SKILL.md b/Api.ErrorHandling/.claude/skills/nano-add-entity/SKILL.md index d9f45740..46b8a48c 100644 --- a/Api.ErrorHandling/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.ErrorHandling/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.ErrorHandling/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Api.ErrorHandling/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Api.ErrorHandling/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Api.ErrorHandling/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.ErrorHandling/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Api.ErrorHandling/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.ErrorHandling/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.ErrorHandling/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Api.ErrorHandling/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.ErrorHandling/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only diff --git a/Api.ErrorHandling/.github/prompts/nano-add-entity.prompt.md b/Api.ErrorHandling/.github/prompts/nano-add-entity.prompt.md index f8345ca6..71b15597 100644 --- a/Api.ErrorHandling/.github/prompts/nano-add-entity.prompt.md +++ b/Api.ErrorHandling/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.ErrorHandling/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Api.ErrorHandling/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Api.ErrorHandling/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Api.ErrorHandling/AGENTS.md b/Api.ErrorHandling/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Api.ErrorHandling/AGENTS.md +++ b/Api.ErrorHandling/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Api.Eventing.RabbitMq/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Eventing.RabbitMq/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Api.Eventing.RabbitMq/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Eventing.RabbitMq/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Eventing.RabbitMq/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Api.Eventing.RabbitMq/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Eventing.RabbitMq/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only 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 d9f45740..46b8a48c 100644 --- a/Api.Eventing.RabbitMq/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Eventing.RabbitMq/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Eventing.RabbitMq/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Api.Eventing.RabbitMq/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Api.Eventing.RabbitMq/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Api.Eventing.RabbitMq/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Eventing.RabbitMq/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Api.Eventing.RabbitMq/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Eventing.RabbitMq/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Eventing.RabbitMq/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Api.Eventing.RabbitMq/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Eventing.RabbitMq/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only 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 f8345ca6..71b15597 100644 --- a/Api.Eventing.RabbitMq/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Eventing.RabbitMq/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Eventing.RabbitMq/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Api.Eventing.RabbitMq/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Api.Eventing.RabbitMq/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Api.Eventing.RabbitMq/AGENTS.md b/Api.Eventing.RabbitMq/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Api.Eventing.RabbitMq/AGENTS.md +++ b/Api.Eventing.RabbitMq/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Api.HealthChecks/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.HealthChecks/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Api.HealthChecks/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.HealthChecks/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.HealthChecks/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Api.HealthChecks/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.HealthChecks/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only diff --git a/Api.HealthChecks/.claude/skills/nano-add-entity/SKILL.md b/Api.HealthChecks/.claude/skills/nano-add-entity/SKILL.md index d9f45740..46b8a48c 100644 --- a/Api.HealthChecks/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.HealthChecks/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.HealthChecks/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Api.HealthChecks/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Api.HealthChecks/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Api.HealthChecks/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.HealthChecks/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Api.HealthChecks/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.HealthChecks/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.HealthChecks/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Api.HealthChecks/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.HealthChecks/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only diff --git a/Api.HealthChecks/.github/prompts/nano-add-entity.prompt.md b/Api.HealthChecks/.github/prompts/nano-add-entity.prompt.md index f8345ca6..71b15597 100644 --- a/Api.HealthChecks/.github/prompts/nano-add-entity.prompt.md +++ b/Api.HealthChecks/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.HealthChecks/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Api.HealthChecks/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Api.HealthChecks/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Api.HealthChecks/AGENTS.md b/Api.HealthChecks/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Api.HealthChecks/AGENTS.md +++ b/Api.HealthChecks/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Api.Hosting.Http/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Hosting.Http/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Api.Hosting.Http/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Hosting.Http/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Hosting.Http/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Api.Hosting.Http/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Hosting.Http/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only 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 d9f45740..46b8a48c 100644 --- a/Api.Hosting.Http/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Hosting.Http/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Hosting.Http/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Api.Hosting.Http/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Api.Hosting.Http/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Api.Hosting.Http/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Hosting.Http/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Api.Hosting.Http/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Hosting.Http/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Hosting.Http/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Api.Hosting.Http/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Hosting.Http/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only 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 f8345ca6..71b15597 100644 --- a/Api.Hosting.Http/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Hosting.Http/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Hosting.Http/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Api.Hosting.Http/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Api.Hosting.Http/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Api.Hosting.Http/AGENTS.md b/Api.Hosting.Http/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Api.Hosting.Http/AGENTS.md +++ b/Api.Hosting.Http/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Api.Hosting.Https/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Hosting.Https/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Api.Hosting.Https/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Hosting.Https/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Hosting.Https/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Api.Hosting.Https/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Hosting.Https/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only 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 d9f45740..46b8a48c 100644 --- a/Api.Hosting.Https/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Hosting.Https/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Hosting.Https/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Api.Hosting.Https/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Api.Hosting.Https/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Api.Hosting.Https/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Hosting.Https/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Api.Hosting.Https/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Hosting.Https/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Hosting.Https/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Api.Hosting.Https/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Hosting.Https/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only 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 f8345ca6..71b15597 100644 --- a/Api.Hosting.Https/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Hosting.Https/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Hosting.Https/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Api.Hosting.Https/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Api.Hosting.Https/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Api.Hosting.Https/AGENTS.md b/Api.Hosting.Https/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Api.Hosting.Https/AGENTS.md +++ b/Api.Hosting.Https/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Api.Hosting.MultipartLimits/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Hosting.MultipartLimits/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Api.Hosting.MultipartLimits/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Hosting.MultipartLimits/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Hosting.MultipartLimits/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Api.Hosting.MultipartLimits/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Hosting.MultipartLimits/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only 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 d9f45740..46b8a48c 100644 --- a/Api.Hosting.MultipartLimits/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Hosting.MultipartLimits/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Hosting.MultipartLimits/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Api.Hosting.MultipartLimits/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Api.Hosting.MultipartLimits/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Api.Hosting.MultipartLimits/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Hosting.MultipartLimits/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Api.Hosting.MultipartLimits/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Hosting.MultipartLimits/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Hosting.MultipartLimits/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Api.Hosting.MultipartLimits/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Hosting.MultipartLimits/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only 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 f8345ca6..71b15597 100644 --- a/Api.Hosting.MultipartLimits/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Hosting.MultipartLimits/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Hosting.MultipartLimits/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Api.Hosting.MultipartLimits/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Api.Hosting.MultipartLimits/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Api.Hosting.MultipartLimits/AGENTS.md b/Api.Hosting.MultipartLimits/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Api.Hosting.MultipartLimits/AGENTS.md +++ b/Api.Hosting.MultipartLimits/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Api.Localization/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Localization/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Api.Localization/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Localization/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Localization/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Api.Localization/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Localization/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only diff --git a/Api.Localization/.claude/skills/nano-add-entity/SKILL.md b/Api.Localization/.claude/skills/nano-add-entity/SKILL.md index d9f45740..46b8a48c 100644 --- a/Api.Localization/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Localization/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Localization/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Api.Localization/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Api.Localization/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Api.Localization/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Localization/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Api.Localization/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Localization/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Localization/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Api.Localization/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Localization/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only diff --git a/Api.Localization/.github/prompts/nano-add-entity.prompt.md b/Api.Localization/.github/prompts/nano-add-entity.prompt.md index f8345ca6..71b15597 100644 --- a/Api.Localization/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Localization/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Localization/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Api.Localization/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Api.Localization/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Api.Localization/AGENTS.md b/Api.Localization/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Api.Localization/AGENTS.md +++ b/Api.Localization/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Api.Logging.Log4Net/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Logging.Log4Net/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Api.Logging.Log4Net/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Logging.Log4Net/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Logging.Log4Net/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Api.Logging.Log4Net/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Logging.Log4Net/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only 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 d9f45740..46b8a48c 100644 --- a/Api.Logging.Log4Net/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Logging.Log4Net/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Logging.Log4Net/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Api.Logging.Log4Net/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Api.Logging.Log4Net/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Api.Logging.Log4Net/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Logging.Log4Net/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Api.Logging.Log4Net/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Logging.Log4Net/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Logging.Log4Net/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Api.Logging.Log4Net/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Logging.Log4Net/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only 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 f8345ca6..71b15597 100644 --- a/Api.Logging.Log4Net/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Logging.Log4Net/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Logging.Log4Net/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Api.Logging.Log4Net/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Api.Logging.Log4Net/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Api.Logging.Log4Net/AGENTS.md b/Api.Logging.Log4Net/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Api.Logging.Log4Net/AGENTS.md +++ b/Api.Logging.Log4Net/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Api.Logging.Microsoft/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Logging.Microsoft/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Api.Logging.Microsoft/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Logging.Microsoft/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Logging.Microsoft/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Api.Logging.Microsoft/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Logging.Microsoft/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only 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 d9f45740..46b8a48c 100644 --- a/Api.Logging.Microsoft/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Logging.Microsoft/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Logging.Microsoft/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Api.Logging.Microsoft/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Api.Logging.Microsoft/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Api.Logging.Microsoft/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Logging.Microsoft/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Api.Logging.Microsoft/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Logging.Microsoft/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Logging.Microsoft/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Api.Logging.Microsoft/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Logging.Microsoft/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only 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 f8345ca6..71b15597 100644 --- a/Api.Logging.Microsoft/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Logging.Microsoft/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Logging.Microsoft/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Api.Logging.Microsoft/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Api.Logging.Microsoft/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Api.Logging.Microsoft/AGENTS.md b/Api.Logging.Microsoft/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Api.Logging.Microsoft/AGENTS.md +++ b/Api.Logging.Microsoft/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Api.Logging.NLog/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Logging.NLog/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Api.Logging.NLog/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Logging.NLog/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Logging.NLog/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Api.Logging.NLog/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Logging.NLog/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only 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 d9f45740..46b8a48c 100644 --- a/Api.Logging.NLog/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Logging.NLog/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Logging.NLog/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Api.Logging.NLog/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Api.Logging.NLog/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Api.Logging.NLog/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Logging.NLog/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Api.Logging.NLog/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Logging.NLog/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Logging.NLog/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Api.Logging.NLog/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Logging.NLog/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only 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 f8345ca6..71b15597 100644 --- a/Api.Logging.NLog/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Logging.NLog/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Logging.NLog/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Api.Logging.NLog/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Api.Logging.NLog/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Api.Logging.NLog/AGENTS.md b/Api.Logging.NLog/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Api.Logging.NLog/AGENTS.md +++ b/Api.Logging.NLog/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Api.Logging.Serilog/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Logging.Serilog/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Api.Logging.Serilog/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Logging.Serilog/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Logging.Serilog/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Api.Logging.Serilog/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Logging.Serilog/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only 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 d9f45740..46b8a48c 100644 --- a/Api.Logging.Serilog/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Logging.Serilog/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Logging.Serilog/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Api.Logging.Serilog/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Api.Logging.Serilog/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Api.Logging.Serilog/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Logging.Serilog/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Api.Logging.Serilog/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Logging.Serilog/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Logging.Serilog/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Api.Logging.Serilog/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Logging.Serilog/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only 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 f8345ca6..71b15597 100644 --- a/Api.Logging.Serilog/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Logging.Serilog/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Logging.Serilog/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Api.Logging.Serilog/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Api.Logging.Serilog/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Api.Logging.Serilog/AGENTS.md b/Api.Logging.Serilog/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Api.Logging.Serilog/AGENTS.md +++ b/Api.Logging.Serilog/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Api.Metrics/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Metrics/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Api.Metrics/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Metrics/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Metrics/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Api.Metrics/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Metrics/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only diff --git a/Api.Metrics/.claude/skills/nano-add-entity/SKILL.md b/Api.Metrics/.claude/skills/nano-add-entity/SKILL.md index d9f45740..46b8a48c 100644 --- a/Api.Metrics/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Metrics/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Metrics/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Api.Metrics/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Api.Metrics/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Api.Metrics/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Metrics/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Api.Metrics/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Metrics/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Metrics/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Api.Metrics/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Metrics/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only diff --git a/Api.Metrics/.github/prompts/nano-add-entity.prompt.md b/Api.Metrics/.github/prompts/nano-add-entity.prompt.md index f8345ca6..71b15597 100644 --- a/Api.Metrics/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Metrics/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Metrics/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Api.Metrics/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Api.Metrics/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Api.Metrics/AGENTS.md b/Api.Metrics/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Api.Metrics/AGENTS.md +++ b/Api.Metrics/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Api.MultipartJson/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.MultipartJson/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Api.MultipartJson/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.MultipartJson/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.MultipartJson/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Api.MultipartJson/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.MultipartJson/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only diff --git a/Api.MultipartJson/.claude/skills/nano-add-entity/SKILL.md b/Api.MultipartJson/.claude/skills/nano-add-entity/SKILL.md index d9f45740..46b8a48c 100644 --- a/Api.MultipartJson/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.MultipartJson/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.MultipartJson/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Api.MultipartJson/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Api.MultipartJson/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Api.MultipartJson/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.MultipartJson/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Api.MultipartJson/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.MultipartJson/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.MultipartJson/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Api.MultipartJson/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.MultipartJson/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only diff --git a/Api.MultipartJson/.github/prompts/nano-add-entity.prompt.md b/Api.MultipartJson/.github/prompts/nano-add-entity.prompt.md index f8345ca6..71b15597 100644 --- a/Api.MultipartJson/.github/prompts/nano-add-entity.prompt.md +++ b/Api.MultipartJson/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.MultipartJson/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Api.MultipartJson/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Api.MultipartJson/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Api.MultipartJson/AGENTS.md b/Api.MultipartJson/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Api.MultipartJson/AGENTS.md +++ b/Api.MultipartJson/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only 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 d9f45740..46b8a48c 100644 --- a/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only 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 f8345ca6..71b15597 100644 --- a/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-entity.prompt.md +++ b/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Api.PolicyHeaders.ContentSecurityPolicy/AGENTS.md b/Api.PolicyHeaders.ContentSecurityPolicy/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Api.PolicyHeaders.ContentSecurityPolicy/AGENTS.md +++ b/Api.PolicyHeaders.ContentSecurityPolicy/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only 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 d9f45740..46b8a48c 100644 --- a/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only 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 f8345ca6..71b15597 100644 --- a/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-entity.prompt.md +++ b/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Api.PolicyHeaders.ContentTypeOptions/AGENTS.md b/Api.PolicyHeaders.ContentTypeOptions/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Api.PolicyHeaders.ContentTypeOptions/AGENTS.md +++ b/Api.PolicyHeaders.ContentTypeOptions/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Api.PolicyHeaders.Cors/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.PolicyHeaders.Cors/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Api.PolicyHeaders.Cors/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.PolicyHeaders.Cors/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.PolicyHeaders.Cors/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Api.PolicyHeaders.Cors/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.PolicyHeaders.Cors/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only 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 d9f45740..46b8a48c 100644 --- a/Api.PolicyHeaders.Cors/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.PolicyHeaders.Cors/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.PolicyHeaders.Cors/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Api.PolicyHeaders.Cors/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Api.PolicyHeaders.Cors/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Api.PolicyHeaders.Cors/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.PolicyHeaders.Cors/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Api.PolicyHeaders.Cors/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.PolicyHeaders.Cors/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.PolicyHeaders.Cors/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Api.PolicyHeaders.Cors/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.PolicyHeaders.Cors/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only 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 f8345ca6..71b15597 100644 --- a/Api.PolicyHeaders.Cors/.github/prompts/nano-add-entity.prompt.md +++ b/Api.PolicyHeaders.Cors/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.PolicyHeaders.Cors/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Api.PolicyHeaders.Cors/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Api.PolicyHeaders.Cors/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Api.PolicyHeaders.Cors/AGENTS.md b/Api.PolicyHeaders.Cors/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Api.PolicyHeaders.Cors/AGENTS.md +++ b/Api.PolicyHeaders.Cors/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only 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 d9f45740..46b8a48c 100644 --- a/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only 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 f8345ca6..71b15597 100644 --- a/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-entity.prompt.md +++ b/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Api.PolicyHeaders.ForwardedHeaders/AGENTS.md b/Api.PolicyHeaders.ForwardedHeaders/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Api.PolicyHeaders.ForwardedHeaders/AGENTS.md +++ b/Api.PolicyHeaders.ForwardedHeaders/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only 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 d9f45740..46b8a48c 100644 --- a/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only 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 f8345ca6..71b15597 100644 --- a/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-entity.prompt.md +++ b/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Api.PolicyHeaders.FrameOptions/AGENTS.md b/Api.PolicyHeaders.FrameOptions/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Api.PolicyHeaders.FrameOptions/AGENTS.md +++ b/Api.PolicyHeaders.FrameOptions/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only 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 d9f45740..46b8a48c 100644 --- a/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.PolicyHeaders.Hsts/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Api.PolicyHeaders.Hsts/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Api.PolicyHeaders.Hsts/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only 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 f8345ca6..71b15597 100644 --- a/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-entity.prompt.md +++ b/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.PolicyHeaders.Hsts/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Api.PolicyHeaders.Hsts/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Api.PolicyHeaders.Hsts/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Api.PolicyHeaders.Hsts/AGENTS.md b/Api.PolicyHeaders.Hsts/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Api.PolicyHeaders.Hsts/AGENTS.md +++ b/Api.PolicyHeaders.Hsts/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only 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 d9f45740..46b8a48c 100644 --- a/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only 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 f8345ca6..71b15597 100644 --- a/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-entity.prompt.md +++ b/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Api.PolicyHeaders.ReferrerPolicy/AGENTS.md b/Api.PolicyHeaders.ReferrerPolicy/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Api.PolicyHeaders.ReferrerPolicy/AGENTS.md +++ b/Api.PolicyHeaders.ReferrerPolicy/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Api.PolicyHeaders.Robots/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.PolicyHeaders.Robots/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Api.PolicyHeaders.Robots/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.PolicyHeaders.Robots/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.PolicyHeaders.Robots/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Api.PolicyHeaders.Robots/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.PolicyHeaders.Robots/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only 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 d9f45740..46b8a48c 100644 --- a/Api.PolicyHeaders.Robots/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.PolicyHeaders.Robots/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.PolicyHeaders.Robots/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Api.PolicyHeaders.Robots/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Api.PolicyHeaders.Robots/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Api.PolicyHeaders.Robots/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.PolicyHeaders.Robots/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Api.PolicyHeaders.Robots/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.PolicyHeaders.Robots/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.PolicyHeaders.Robots/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Api.PolicyHeaders.Robots/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.PolicyHeaders.Robots/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only 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 f8345ca6..71b15597 100644 --- a/Api.PolicyHeaders.Robots/.github/prompts/nano-add-entity.prompt.md +++ b/Api.PolicyHeaders.Robots/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.PolicyHeaders.Robots/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Api.PolicyHeaders.Robots/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Api.PolicyHeaders.Robots/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Api.PolicyHeaders.Robots/AGENTS.md b/Api.PolicyHeaders.Robots/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Api.PolicyHeaders.Robots/AGENTS.md +++ b/Api.PolicyHeaders.Robots/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only 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 d9f45740..46b8a48c 100644 --- a/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.PolicyHeaders.XssProtection/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Api.PolicyHeaders.XssProtection/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Api.PolicyHeaders.XssProtection/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only 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 f8345ca6..71b15597 100644 --- a/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-entity.prompt.md +++ b/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.PolicyHeaders.XssProtection/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Api.PolicyHeaders.XssProtection/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Api.PolicyHeaders.XssProtection/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Api.PolicyHeaders.XssProtection/AGENTS.md b/Api.PolicyHeaders.XssProtection/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Api.PolicyHeaders.XssProtection/AGENTS.md +++ b/Api.PolicyHeaders.XssProtection/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Api.RequestTracing/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.RequestTracing/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Api.RequestTracing/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.RequestTracing/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.RequestTracing/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Api.RequestTracing/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.RequestTracing/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only diff --git a/Api.RequestTracing/.claude/skills/nano-add-entity/SKILL.md b/Api.RequestTracing/.claude/skills/nano-add-entity/SKILL.md index d9f45740..46b8a48c 100644 --- a/Api.RequestTracing/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.RequestTracing/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.RequestTracing/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Api.RequestTracing/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Api.RequestTracing/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Api.RequestTracing/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.RequestTracing/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Api.RequestTracing/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.RequestTracing/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.RequestTracing/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Api.RequestTracing/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.RequestTracing/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only diff --git a/Api.RequestTracing/.github/prompts/nano-add-entity.prompt.md b/Api.RequestTracing/.github/prompts/nano-add-entity.prompt.md index f8345ca6..71b15597 100644 --- a/Api.RequestTracing/.github/prompts/nano-add-entity.prompt.md +++ b/Api.RequestTracing/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.RequestTracing/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Api.RequestTracing/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Api.RequestTracing/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Api.RequestTracing/AGENTS.md b/Api.RequestTracing/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Api.RequestTracing/AGENTS.md +++ b/Api.RequestTracing/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Api.ResponseCache/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.ResponseCache/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Api.ResponseCache/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.ResponseCache/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.ResponseCache/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Api.ResponseCache/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.ResponseCache/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only diff --git a/Api.ResponseCache/.claude/skills/nano-add-entity/SKILL.md b/Api.ResponseCache/.claude/skills/nano-add-entity/SKILL.md index d9f45740..46b8a48c 100644 --- a/Api.ResponseCache/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.ResponseCache/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.ResponseCache/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Api.ResponseCache/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Api.ResponseCache/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Api.ResponseCache/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.ResponseCache/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Api.ResponseCache/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.ResponseCache/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.ResponseCache/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Api.ResponseCache/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.ResponseCache/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only diff --git a/Api.ResponseCache/.github/prompts/nano-add-entity.prompt.md b/Api.ResponseCache/.github/prompts/nano-add-entity.prompt.md index f8345ca6..71b15597 100644 --- a/Api.ResponseCache/.github/prompts/nano-add-entity.prompt.md +++ b/Api.ResponseCache/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.ResponseCache/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Api.ResponseCache/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Api.ResponseCache/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Api.ResponseCache/AGENTS.md b/Api.ResponseCache/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Api.ResponseCache/AGENTS.md +++ b/Api.ResponseCache/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Api.ResponseCompression/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.ResponseCompression/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Api.ResponseCompression/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.ResponseCompression/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.ResponseCompression/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Api.ResponseCompression/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.ResponseCompression/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only diff --git a/Api.ResponseCompression/.claude/skills/nano-add-entity/SKILL.md b/Api.ResponseCompression/.claude/skills/nano-add-entity/SKILL.md index d9f45740..46b8a48c 100644 --- a/Api.ResponseCompression/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.ResponseCompression/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.ResponseCompression/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Api.ResponseCompression/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Api.ResponseCompression/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Api.ResponseCompression/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.ResponseCompression/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Api.ResponseCompression/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.ResponseCompression/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.ResponseCompression/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Api.ResponseCompression/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.ResponseCompression/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only diff --git a/Api.ResponseCompression/.github/prompts/nano-add-entity.prompt.md b/Api.ResponseCompression/.github/prompts/nano-add-entity.prompt.md index f8345ca6..71b15597 100644 --- a/Api.ResponseCompression/.github/prompts/nano-add-entity.prompt.md +++ b/Api.ResponseCompression/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.ResponseCompression/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Api.ResponseCompression/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Api.ResponseCompression/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Api.ResponseCompression/AGENTS.md b/Api.ResponseCompression/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Api.ResponseCompression/AGENTS.md +++ b/Api.ResponseCompression/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Api.Session/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Session/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Api.Session/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Session/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Session/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Api.Session/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Session/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only diff --git a/Api.Session/.claude/skills/nano-add-entity/SKILL.md b/Api.Session/.claude/skills/nano-add-entity/SKILL.md index d9f45740..46b8a48c 100644 --- a/Api.Session/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Session/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Session/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Api.Session/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Api.Session/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Api.Session/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Session/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Api.Session/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Session/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Session/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Api.Session/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Session/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only diff --git a/Api.Session/.github/prompts/nano-add-entity.prompt.md b/Api.Session/.github/prompts/nano-add-entity.prompt.md index f8345ca6..71b15597 100644 --- a/Api.Session/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Session/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Session/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Api.Session/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Api.Session/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Api.Session/AGENTS.md b/Api.Session/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Api.Session/AGENTS.md +++ b/Api.Session/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Api.StartupTasks/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.StartupTasks/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Api.StartupTasks/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.StartupTasks/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.StartupTasks/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Api.StartupTasks/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.StartupTasks/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only diff --git a/Api.StartupTasks/.claude/skills/nano-add-entity/SKILL.md b/Api.StartupTasks/.claude/skills/nano-add-entity/SKILL.md index d9f45740..46b8a48c 100644 --- a/Api.StartupTasks/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.StartupTasks/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.StartupTasks/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Api.StartupTasks/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Api.StartupTasks/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Api.StartupTasks/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.StartupTasks/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Api.StartupTasks/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.StartupTasks/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.StartupTasks/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Api.StartupTasks/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.StartupTasks/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only diff --git a/Api.StartupTasks/.github/prompts/nano-add-entity.prompt.md b/Api.StartupTasks/.github/prompts/nano-add-entity.prompt.md index f8345ca6..71b15597 100644 --- a/Api.StartupTasks/.github/prompts/nano-add-entity.prompt.md +++ b/Api.StartupTasks/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.StartupTasks/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Api.StartupTasks/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Api.StartupTasks/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Api.StartupTasks/AGENTS.md b/Api.StartupTasks/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Api.StartupTasks/AGENTS.md +++ b/Api.StartupTasks/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Api.StaticFiles/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.StaticFiles/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Api.StaticFiles/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.StaticFiles/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.StaticFiles/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Api.StaticFiles/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.StaticFiles/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only diff --git a/Api.StaticFiles/.claude/skills/nano-add-entity/SKILL.md b/Api.StaticFiles/.claude/skills/nano-add-entity/SKILL.md index d9f45740..46b8a48c 100644 --- a/Api.StaticFiles/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.StaticFiles/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.StaticFiles/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Api.StaticFiles/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Api.StaticFiles/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Api.StaticFiles/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.StaticFiles/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Api.StaticFiles/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.StaticFiles/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.StaticFiles/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Api.StaticFiles/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.StaticFiles/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only diff --git a/Api.StaticFiles/.github/prompts/nano-add-entity.prompt.md b/Api.StaticFiles/.github/prompts/nano-add-entity.prompt.md index f8345ca6..71b15597 100644 --- a/Api.StaticFiles/.github/prompts/nano-add-entity.prompt.md +++ b/Api.StaticFiles/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.StaticFiles/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Api.StaticFiles/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Api.StaticFiles/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Api.StaticFiles/AGENTS.md b/Api.StaticFiles/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Api.StaticFiles/AGENTS.md +++ b/Api.StaticFiles/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Api.Storage.Azure/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Storage.Azure/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Api.Storage.Azure/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Storage.Azure/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Storage.Azure/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Api.Storage.Azure/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Storage.Azure/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only 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 d9f45740..46b8a48c 100644 --- a/Api.Storage.Azure/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Storage.Azure/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Storage.Azure/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Api.Storage.Azure/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Api.Storage.Azure/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Api.Storage.Azure/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Storage.Azure/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Api.Storage.Azure/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Storage.Azure/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Storage.Azure/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Api.Storage.Azure/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Storage.Azure/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only 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 f8345ca6..71b15597 100644 --- a/Api.Storage.Azure/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Storage.Azure/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Storage.Azure/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Api.Storage.Azure/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Api.Storage.Azure/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Api.Storage.Azure/AGENTS.md b/Api.Storage.Azure/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Api.Storage.Azure/AGENTS.md +++ b/Api.Storage.Azure/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Api.Storage.Local/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Storage.Local/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Api.Storage.Local/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Storage.Local/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Storage.Local/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Api.Storage.Local/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Storage.Local/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only 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 d9f45740..46b8a48c 100644 --- a/Api.Storage.Local/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Storage.Local/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Storage.Local/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Api.Storage.Local/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Api.Storage.Local/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Api.Storage.Local/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Storage.Local/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Api.Storage.Local/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Storage.Local/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Storage.Local/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Api.Storage.Local/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Storage.Local/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only 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 f8345ca6..71b15597 100644 --- a/Api.Storage.Local/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Storage.Local/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Storage.Local/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Api.Storage.Local/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Api.Storage.Local/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Api.Storage.Local/AGENTS.md b/Api.Storage.Local/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Api.Storage.Local/AGENTS.md +++ b/Api.Storage.Local/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Api.TimeZone/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.TimeZone/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Api.TimeZone/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.TimeZone/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.TimeZone/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Api.TimeZone/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.TimeZone/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only diff --git a/Api.TimeZone/.claude/skills/nano-add-entity/SKILL.md b/Api.TimeZone/.claude/skills/nano-add-entity/SKILL.md index d9f45740..46b8a48c 100644 --- a/Api.TimeZone/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.TimeZone/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.TimeZone/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Api.TimeZone/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Api.TimeZone/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Api.TimeZone/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.TimeZone/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Api.TimeZone/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.TimeZone/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.TimeZone/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Api.TimeZone/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.TimeZone/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only diff --git a/Api.TimeZone/.github/prompts/nano-add-entity.prompt.md b/Api.TimeZone/.github/prompts/nano-add-entity.prompt.md index f8345ca6..71b15597 100644 --- a/Api.TimeZone/.github/prompts/nano-add-entity.prompt.md +++ b/Api.TimeZone/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.TimeZone/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Api.TimeZone/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Api.TimeZone/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Api.TimeZone/AGENTS.md b/Api.TimeZone/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Api.TimeZone/AGENTS.md +++ b/Api.TimeZone/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Api.Versioning/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Versioning/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Api.Versioning/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Versioning/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.Versioning/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Api.Versioning/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.Versioning/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only diff --git a/Api.Versioning/.claude/skills/nano-add-entity/SKILL.md b/Api.Versioning/.claude/skills/nano-add-entity/SKILL.md index d9f45740..46b8a48c 100644 --- a/Api.Versioning/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Versioning/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Versioning/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Api.Versioning/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Api.Versioning/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Api.Versioning/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Versioning/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Api.Versioning/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.Versioning/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.Versioning/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Api.Versioning/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.Versioning/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only diff --git a/Api.Versioning/.github/prompts/nano-add-entity.prompt.md b/Api.Versioning/.github/prompts/nano-add-entity.prompt.md index f8345ca6..71b15597 100644 --- a/Api.Versioning/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Versioning/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.Versioning/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Api.Versioning/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Api.Versioning/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Api.Versioning/AGENTS.md b/Api.Versioning/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Api.Versioning/AGENTS.md +++ b/Api.Versioning/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Api.VirusScan/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.VirusScan/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Api.VirusScan/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.VirusScan/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api.VirusScan/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Api.VirusScan/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.VirusScan/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only diff --git a/Api.VirusScan/.claude/skills/nano-add-entity/SKILL.md b/Api.VirusScan/.claude/skills/nano-add-entity/SKILL.md index d9f45740..46b8a48c 100644 --- a/Api.VirusScan/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.VirusScan/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.VirusScan/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Api.VirusScan/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Api.VirusScan/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Api.VirusScan/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.VirusScan/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Api.VirusScan/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api.VirusScan/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api.VirusScan/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Api.VirusScan/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.VirusScan/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only diff --git a/Api.VirusScan/.github/prompts/nano-add-entity.prompt.md b/Api.VirusScan/.github/prompts/nano-add-entity.prompt.md index f8345ca6..71b15597 100644 --- a/Api.VirusScan/.github/prompts/nano-add-entity.prompt.md +++ b/Api.VirusScan/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api.VirusScan/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Api.VirusScan/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Api.VirusScan/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Api.VirusScan/AGENTS.md b/Api.VirusScan/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Api.VirusScan/AGENTS.md +++ b/Api.VirusScan/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Api._Blank/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api._Blank/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Api._Blank/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api._Blank/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Api._Blank/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Api._Blank/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api._Blank/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only diff --git a/Api._Blank/.claude/skills/nano-add-entity/SKILL.md b/Api._Blank/.claude/skills/nano-add-entity/SKILL.md index d9f45740..46b8a48c 100644 --- a/Api._Blank/.claude/skills/nano-add-entity/SKILL.md +++ b/Api._Blank/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api._Blank/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Api._Blank/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Api._Blank/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Api._Blank/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api._Blank/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Api._Blank/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Api._Blank/.github/prompts/nano-add-authentication-jwt.prompt.md b/Api._Blank/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Api._Blank/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api._Blank/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only diff --git a/Api._Blank/.github/prompts/nano-add-entity.prompt.md b/Api._Blank/.github/prompts/nano-add-entity.prompt.md index f8345ca6..71b15597 100644 --- a/Api._Blank/.github/prompts/nano-add-entity.prompt.md +++ b/Api._Blank/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Api._Blank/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Api._Blank/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Api._Blank/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Api._Blank/AGENTS.md b/Api._Blank/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Api._Blank/AGENTS.md +++ b/Api._Blank/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Console.CustomConfigSection/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Console.CustomConfigSection/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Console.CustomConfigSection/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Console.CustomConfigSection/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Console.CustomConfigSection/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Console.CustomConfigSection/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Console.CustomConfigSection/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only diff --git a/Console.CustomConfigSection/.claude/skills/nano-add-entity/SKILL.md b/Console.CustomConfigSection/.claude/skills/nano-add-entity/SKILL.md index d9f45740..46b8a48c 100644 --- a/Console.CustomConfigSection/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.CustomConfigSection/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Console.CustomConfigSection/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Console.CustomConfigSection/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Console.CustomConfigSection/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Console.CustomConfigSection/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Console.CustomConfigSection/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Console.CustomConfigSection/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Console.CustomConfigSection/.github/prompts/nano-add-authentication-jwt.prompt.md b/Console.CustomConfigSection/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Console.CustomConfigSection/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Console.CustomConfigSection/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only diff --git a/Console.CustomConfigSection/.github/prompts/nano-add-entity.prompt.md b/Console.CustomConfigSection/.github/prompts/nano-add-entity.prompt.md index f8345ca6..71b15597 100644 --- a/Console.CustomConfigSection/.github/prompts/nano-add-entity.prompt.md +++ b/Console.CustomConfigSection/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Console.CustomConfigSection/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Console.CustomConfigSection/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Console.CustomConfigSection/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Console.CustomConfigSection/AGENTS.md b/Console.CustomConfigSection/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Console.CustomConfigSection/AGENTS.md +++ b/Console.CustomConfigSection/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Console.CustomService/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Console.CustomService/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Console.CustomService/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Console.CustomService/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Console.CustomService/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Console.CustomService/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Console.CustomService/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only diff --git a/Console.CustomService/.claude/skills/nano-add-entity/SKILL.md b/Console.CustomService/.claude/skills/nano-add-entity/SKILL.md index d9f45740..46b8a48c 100644 --- a/Console.CustomService/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.CustomService/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Console.CustomService/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Console.CustomService/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Console.CustomService/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Console.CustomService/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Console.CustomService/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Console.CustomService/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Console.CustomService/.github/prompts/nano-add-authentication-jwt.prompt.md b/Console.CustomService/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Console.CustomService/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Console.CustomService/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only diff --git a/Console.CustomService/.github/prompts/nano-add-entity.prompt.md b/Console.CustomService/.github/prompts/nano-add-entity.prompt.md index f8345ca6..71b15597 100644 --- a/Console.CustomService/.github/prompts/nano-add-entity.prompt.md +++ b/Console.CustomService/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Console.CustomService/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Console.CustomService/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Console.CustomService/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Console.CustomService/AGENTS.md b/Console.CustomService/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Console.CustomService/AGENTS.md +++ b/Console.CustomService/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Console.Data.InMemory/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Console.Data.InMemory/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Console.Data.InMemory/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Console.Data.InMemory/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Console.Data.InMemory/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Console.Data.InMemory/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Console.Data.InMemory/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only 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 d9f45740..46b8a48c 100644 --- a/Console.Data.InMemory/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Data.InMemory/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Console.Data.InMemory/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Console.Data.InMemory/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Console.Data.InMemory/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Console.Data.InMemory/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Console.Data.InMemory/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Console.Data.InMemory/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Console.Data.InMemory/.github/prompts/nano-add-authentication-jwt.prompt.md b/Console.Data.InMemory/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Console.Data.InMemory/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Console.Data.InMemory/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only 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 f8345ca6..71b15597 100644 --- a/Console.Data.InMemory/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Data.InMemory/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Console.Data.InMemory/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Console.Data.InMemory/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Console.Data.InMemory/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Console.Data.InMemory/AGENTS.md b/Console.Data.InMemory/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Console.Data.InMemory/AGENTS.md +++ b/Console.Data.InMemory/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Console.Data.MySql/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Console.Data.MySql/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Console.Data.MySql/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Console.Data.MySql/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Console.Data.MySql/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Console.Data.MySql/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Console.Data.MySql/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only 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 d9f45740..46b8a48c 100644 --- a/Console.Data.MySql/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Data.MySql/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Console.Data.MySql/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Console.Data.MySql/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Console.Data.MySql/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Console.Data.MySql/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Console.Data.MySql/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Console.Data.MySql/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Console.Data.MySql/.github/prompts/nano-add-authentication-jwt.prompt.md b/Console.Data.MySql/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Console.Data.MySql/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Console.Data.MySql/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only 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 f8345ca6..71b15597 100644 --- a/Console.Data.MySql/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Data.MySql/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Console.Data.MySql/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Console.Data.MySql/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Console.Data.MySql/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Console.Data.MySql/AGENTS.md b/Console.Data.MySql/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Console.Data.MySql/AGENTS.md +++ b/Console.Data.MySql/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Console.Data.PostgreSQL/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Console.Data.PostgreSQL/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Console.Data.PostgreSQL/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Console.Data.PostgreSQL/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Console.Data.PostgreSQL/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Console.Data.PostgreSQL/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Console.Data.PostgreSQL/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only 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 d9f45740..46b8a48c 100644 --- a/Console.Data.PostgreSQL/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Data.PostgreSQL/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Console.Data.PostgreSQL/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Console.Data.PostgreSQL/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Console.Data.PostgreSQL/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Console.Data.PostgreSQL/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Console.Data.PostgreSQL/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Console.Data.PostgreSQL/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Console.Data.PostgreSQL/.github/prompts/nano-add-authentication-jwt.prompt.md b/Console.Data.PostgreSQL/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Console.Data.PostgreSQL/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Console.Data.PostgreSQL/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only 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 f8345ca6..71b15597 100644 --- a/Console.Data.PostgreSQL/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Data.PostgreSQL/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Console.Data.PostgreSQL/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Console.Data.PostgreSQL/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Console.Data.PostgreSQL/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Console.Data.PostgreSQL/AGENTS.md b/Console.Data.PostgreSQL/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Console.Data.PostgreSQL/AGENTS.md +++ b/Console.Data.PostgreSQL/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Console.Data.SqLite/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Console.Data.SqLite/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Console.Data.SqLite/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Console.Data.SqLite/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Console.Data.SqLite/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Console.Data.SqLite/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Console.Data.SqLite/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only 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 d9f45740..46b8a48c 100644 --- a/Console.Data.SqLite/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Data.SqLite/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Console.Data.SqLite/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Console.Data.SqLite/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Console.Data.SqLite/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Console.Data.SqLite/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Console.Data.SqLite/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Console.Data.SqLite/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Console.Data.SqLite/.github/prompts/nano-add-authentication-jwt.prompt.md b/Console.Data.SqLite/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Console.Data.SqLite/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Console.Data.SqLite/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only 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 f8345ca6..71b15597 100644 --- a/Console.Data.SqLite/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Data.SqLite/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Console.Data.SqLite/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Console.Data.SqLite/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Console.Data.SqLite/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Console.Data.SqLite/AGENTS.md b/Console.Data.SqLite/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Console.Data.SqLite/AGENTS.md +++ b/Console.Data.SqLite/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Console.Data.SqlServer/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Console.Data.SqlServer/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Console.Data.SqlServer/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Console.Data.SqlServer/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Console.Data.SqlServer/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Console.Data.SqlServer/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Console.Data.SqlServer/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only 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 d9f45740..46b8a48c 100644 --- a/Console.Data.SqlServer/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Data.SqlServer/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Console.Data.SqlServer/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Console.Data.SqlServer/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Console.Data.SqlServer/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Console.Data.SqlServer/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Console.Data.SqlServer/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Console.Data.SqlServer/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Console.Data.SqlServer/.github/prompts/nano-add-authentication-jwt.prompt.md b/Console.Data.SqlServer/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Console.Data.SqlServer/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Console.Data.SqlServer/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only 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 f8345ca6..71b15597 100644 --- a/Console.Data.SqlServer/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Data.SqlServer/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Console.Data.SqlServer/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Console.Data.SqlServer/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Console.Data.SqlServer/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Console.Data.SqlServer/AGENTS.md b/Console.Data.SqlServer/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Console.Data.SqlServer/AGENTS.md +++ b/Console.Data.SqlServer/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Console.Eventing.RabbitMq/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Console.Eventing.RabbitMq/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Console.Eventing.RabbitMq/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Console.Eventing.RabbitMq/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Console.Eventing.RabbitMq/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Console.Eventing.RabbitMq/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Console.Eventing.RabbitMq/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only 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 d9f45740..46b8a48c 100644 --- a/Console.Eventing.RabbitMq/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Eventing.RabbitMq/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Console.Eventing.RabbitMq/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Console.Eventing.RabbitMq/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Console.Eventing.RabbitMq/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Console.Eventing.RabbitMq/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Console.Eventing.RabbitMq/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Console.Eventing.RabbitMq/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Console.Eventing.RabbitMq/.github/prompts/nano-add-authentication-jwt.prompt.md b/Console.Eventing.RabbitMq/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Console.Eventing.RabbitMq/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Console.Eventing.RabbitMq/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only 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 f8345ca6..71b15597 100644 --- a/Console.Eventing.RabbitMq/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Eventing.RabbitMq/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Console.Eventing.RabbitMq/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Console.Eventing.RabbitMq/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Console.Eventing.RabbitMq/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Console.Eventing.RabbitMq/AGENTS.md b/Console.Eventing.RabbitMq/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Console.Eventing.RabbitMq/AGENTS.md +++ b/Console.Eventing.RabbitMq/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Console.ExceptionHandling/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Console.ExceptionHandling/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Console.ExceptionHandling/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Console.ExceptionHandling/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Console.ExceptionHandling/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Console.ExceptionHandling/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Console.ExceptionHandling/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only diff --git a/Console.ExceptionHandling/.claude/skills/nano-add-entity/SKILL.md b/Console.ExceptionHandling/.claude/skills/nano-add-entity/SKILL.md index d9f45740..46b8a48c 100644 --- a/Console.ExceptionHandling/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.ExceptionHandling/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Console.ExceptionHandling/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Console.ExceptionHandling/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Console.ExceptionHandling/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Console.ExceptionHandling/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Console.ExceptionHandling/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Console.ExceptionHandling/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Console.ExceptionHandling/.github/prompts/nano-add-authentication-jwt.prompt.md b/Console.ExceptionHandling/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Console.ExceptionHandling/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Console.ExceptionHandling/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only diff --git a/Console.ExceptionHandling/.github/prompts/nano-add-entity.prompt.md b/Console.ExceptionHandling/.github/prompts/nano-add-entity.prompt.md index f8345ca6..71b15597 100644 --- a/Console.ExceptionHandling/.github/prompts/nano-add-entity.prompt.md +++ b/Console.ExceptionHandling/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Console.ExceptionHandling/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Console.ExceptionHandling/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Console.ExceptionHandling/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Console.ExceptionHandling/AGENTS.md b/Console.ExceptionHandling/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Console.ExceptionHandling/AGENTS.md +++ b/Console.ExceptionHandling/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Console.Localization/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Console.Localization/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Console.Localization/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Console.Localization/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Console.Localization/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Console.Localization/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Console.Localization/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only diff --git a/Console.Localization/.claude/skills/nano-add-entity/SKILL.md b/Console.Localization/.claude/skills/nano-add-entity/SKILL.md index d9f45740..46b8a48c 100644 --- a/Console.Localization/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Localization/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Console.Localization/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Console.Localization/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Console.Localization/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Console.Localization/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Console.Localization/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Console.Localization/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Console.Localization/.github/prompts/nano-add-authentication-jwt.prompt.md b/Console.Localization/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Console.Localization/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Console.Localization/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only diff --git a/Console.Localization/.github/prompts/nano-add-entity.prompt.md b/Console.Localization/.github/prompts/nano-add-entity.prompt.md index f8345ca6..71b15597 100644 --- a/Console.Localization/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Localization/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Console.Localization/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Console.Localization/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Console.Localization/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Console.Localization/AGENTS.md b/Console.Localization/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Console.Localization/AGENTS.md +++ b/Console.Localization/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Console.Logging.Log4Net/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Console.Logging.Log4Net/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Console.Logging.Log4Net/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Console.Logging.Log4Net/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Console.Logging.Log4Net/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Console.Logging.Log4Net/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Console.Logging.Log4Net/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only 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 d9f45740..46b8a48c 100644 --- a/Console.Logging.Log4Net/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Logging.Log4Net/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Console.Logging.Log4Net/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Console.Logging.Log4Net/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Console.Logging.Log4Net/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Console.Logging.Log4Net/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Console.Logging.Log4Net/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Console.Logging.Log4Net/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Console.Logging.Log4Net/.github/prompts/nano-add-authentication-jwt.prompt.md b/Console.Logging.Log4Net/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Console.Logging.Log4Net/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Console.Logging.Log4Net/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only 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 f8345ca6..71b15597 100644 --- a/Console.Logging.Log4Net/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Logging.Log4Net/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Console.Logging.Log4Net/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Console.Logging.Log4Net/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Console.Logging.Log4Net/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Console.Logging.Log4Net/AGENTS.md b/Console.Logging.Log4Net/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Console.Logging.Log4Net/AGENTS.md +++ b/Console.Logging.Log4Net/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Console.Logging.Microsoft/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Console.Logging.Microsoft/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Console.Logging.Microsoft/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Console.Logging.Microsoft/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Console.Logging.Microsoft/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Console.Logging.Microsoft/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Console.Logging.Microsoft/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only 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 d9f45740..46b8a48c 100644 --- a/Console.Logging.Microsoft/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Logging.Microsoft/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Console.Logging.Microsoft/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Console.Logging.Microsoft/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Console.Logging.Microsoft/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Console.Logging.Microsoft/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Console.Logging.Microsoft/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Console.Logging.Microsoft/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Console.Logging.Microsoft/.github/prompts/nano-add-authentication-jwt.prompt.md b/Console.Logging.Microsoft/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Console.Logging.Microsoft/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Console.Logging.Microsoft/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only 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 f8345ca6..71b15597 100644 --- a/Console.Logging.Microsoft/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Logging.Microsoft/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Console.Logging.Microsoft/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Console.Logging.Microsoft/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Console.Logging.Microsoft/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Console.Logging.Microsoft/AGENTS.md b/Console.Logging.Microsoft/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Console.Logging.Microsoft/AGENTS.md +++ b/Console.Logging.Microsoft/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Console.Logging.NLog/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Console.Logging.NLog/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Console.Logging.NLog/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Console.Logging.NLog/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Console.Logging.NLog/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Console.Logging.NLog/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Console.Logging.NLog/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only 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 d9f45740..46b8a48c 100644 --- a/Console.Logging.NLog/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Logging.NLog/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Console.Logging.NLog/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Console.Logging.NLog/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Console.Logging.NLog/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Console.Logging.NLog/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Console.Logging.NLog/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Console.Logging.NLog/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Console.Logging.NLog/.github/prompts/nano-add-authentication-jwt.prompt.md b/Console.Logging.NLog/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Console.Logging.NLog/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Console.Logging.NLog/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only 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 f8345ca6..71b15597 100644 --- a/Console.Logging.NLog/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Logging.NLog/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Console.Logging.NLog/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Console.Logging.NLog/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Console.Logging.NLog/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Console.Logging.NLog/AGENTS.md b/Console.Logging.NLog/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Console.Logging.NLog/AGENTS.md +++ b/Console.Logging.NLog/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Console.Logging.Serilog/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Console.Logging.Serilog/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Console.Logging.Serilog/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Console.Logging.Serilog/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Console.Logging.Serilog/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Console.Logging.Serilog/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Console.Logging.Serilog/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only 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 d9f45740..46b8a48c 100644 --- a/Console.Logging.Serilog/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Logging.Serilog/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Console.Logging.Serilog/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Console.Logging.Serilog/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Console.Logging.Serilog/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Console.Logging.Serilog/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Console.Logging.Serilog/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Console.Logging.Serilog/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Console.Logging.Serilog/.github/prompts/nano-add-authentication-jwt.prompt.md b/Console.Logging.Serilog/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Console.Logging.Serilog/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Console.Logging.Serilog/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only 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 f8345ca6..71b15597 100644 --- a/Console.Logging.Serilog/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Logging.Serilog/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Console.Logging.Serilog/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Console.Logging.Serilog/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Console.Logging.Serilog/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Console.Logging.Serilog/AGENTS.md b/Console.Logging.Serilog/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Console.Logging.Serilog/AGENTS.md +++ b/Console.Logging.Serilog/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Console.StartupTasks/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Console.StartupTasks/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Console.StartupTasks/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Console.StartupTasks/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Console.StartupTasks/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Console.StartupTasks/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Console.StartupTasks/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only diff --git a/Console.StartupTasks/.claude/skills/nano-add-entity/SKILL.md b/Console.StartupTasks/.claude/skills/nano-add-entity/SKILL.md index d9f45740..46b8a48c 100644 --- a/Console.StartupTasks/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.StartupTasks/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Console.StartupTasks/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Console.StartupTasks/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Console.StartupTasks/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Console.StartupTasks/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Console.StartupTasks/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Console.StartupTasks/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Console.StartupTasks/.github/prompts/nano-add-authentication-jwt.prompt.md b/Console.StartupTasks/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Console.StartupTasks/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Console.StartupTasks/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only diff --git a/Console.StartupTasks/.github/prompts/nano-add-entity.prompt.md b/Console.StartupTasks/.github/prompts/nano-add-entity.prompt.md index f8345ca6..71b15597 100644 --- a/Console.StartupTasks/.github/prompts/nano-add-entity.prompt.md +++ b/Console.StartupTasks/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Console.StartupTasks/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Console.StartupTasks/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Console.StartupTasks/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Console.StartupTasks/AGENTS.md b/Console.StartupTasks/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Console.StartupTasks/AGENTS.md +++ b/Console.StartupTasks/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Console.Storage.Azure/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Console.Storage.Azure/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Console.Storage.Azure/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Console.Storage.Azure/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Console.Storage.Azure/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Console.Storage.Azure/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Console.Storage.Azure/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only 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 d9f45740..46b8a48c 100644 --- a/Console.Storage.Azure/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Storage.Azure/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Console.Storage.Azure/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Console.Storage.Azure/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Console.Storage.Azure/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Console.Storage.Azure/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Console.Storage.Azure/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Console.Storage.Azure/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Console.Storage.Azure/.github/prompts/nano-add-authentication-jwt.prompt.md b/Console.Storage.Azure/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Console.Storage.Azure/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Console.Storage.Azure/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only 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 f8345ca6..71b15597 100644 --- a/Console.Storage.Azure/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Storage.Azure/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Console.Storage.Azure/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Console.Storage.Azure/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Console.Storage.Azure/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Console.Storage.Azure/AGENTS.md b/Console.Storage.Azure/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Console.Storage.Azure/AGENTS.md +++ b/Console.Storage.Azure/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Console.Storage.Local/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Console.Storage.Local/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Console.Storage.Local/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Console.Storage.Local/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Console.Storage.Local/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Console.Storage.Local/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Console.Storage.Local/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only 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 d9f45740..46b8a48c 100644 --- a/Console.Storage.Local/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Storage.Local/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Console.Storage.Local/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Console.Storage.Local/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Console.Storage.Local/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Console.Storage.Local/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Console.Storage.Local/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Console.Storage.Local/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Console.Storage.Local/.github/prompts/nano-add-authentication-jwt.prompt.md b/Console.Storage.Local/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Console.Storage.Local/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Console.Storage.Local/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only 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 f8345ca6..71b15597 100644 --- a/Console.Storage.Local/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Storage.Local/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Console.Storage.Local/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Console.Storage.Local/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Console.Storage.Local/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Console.Storage.Local/AGENTS.md b/Console.Storage.Local/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Console.Storage.Local/AGENTS.md +++ b/Console.Storage.Local/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Console.Workers/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Console.Workers/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Console.Workers/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Console.Workers/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Console.Workers/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Console.Workers/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Console.Workers/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only diff --git a/Console.Workers/.claude/skills/nano-add-entity/SKILL.md b/Console.Workers/.claude/skills/nano-add-entity/SKILL.md index d9f45740..46b8a48c 100644 --- a/Console.Workers/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Workers/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Console.Workers/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Console.Workers/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Console.Workers/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Console.Workers/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Console.Workers/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Console.Workers/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Console.Workers/.github/prompts/nano-add-authentication-jwt.prompt.md b/Console.Workers/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Console.Workers/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Console.Workers/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only diff --git a/Console.Workers/.github/prompts/nano-add-entity.prompt.md b/Console.Workers/.github/prompts/nano-add-entity.prompt.md index f8345ca6..71b15597 100644 --- a/Console.Workers/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Workers/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Console.Workers/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Console.Workers/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Console.Workers/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Console.Workers/AGENTS.md b/Console.Workers/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Console.Workers/AGENTS.md +++ b/Console.Workers/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Console._Blank/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Console._Blank/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Console._Blank/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Console._Blank/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Console._Blank/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Console._Blank/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Console._Blank/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only diff --git a/Console._Blank/.claude/skills/nano-add-entity/SKILL.md b/Console._Blank/.claude/skills/nano-add-entity/SKILL.md index d9f45740..46b8a48c 100644 --- a/Console._Blank/.claude/skills/nano-add-entity/SKILL.md +++ b/Console._Blank/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Console._Blank/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Console._Blank/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Console._Blank/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Console._Blank/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Console._Blank/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Console._Blank/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Console._Blank/.github/prompts/nano-add-authentication-jwt.prompt.md b/Console._Blank/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Console._Blank/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Console._Blank/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only diff --git a/Console._Blank/.github/prompts/nano-add-entity.prompt.md b/Console._Blank/.github/prompts/nano-add-entity.prompt.md index f8345ca6..71b15597 100644 --- a/Console._Blank/.github/prompts/nano-add-entity.prompt.md +++ b/Console._Blank/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Console._Blank/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Console._Blank/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Console._Blank/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Console._Blank/AGENTS.md b/Console._Blank/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Console._Blank/AGENTS.md +++ b/Console._Blank/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two diff --git a/Web._Blank/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Web._Blank/.claude/skills/nano-add-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..57964535 --- /dev/null +++ b/Web._Blank/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -0,0 +1,216 @@ +--- +name: nano-add-authentication-external-custom +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` — it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it — if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold — the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type — nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + — see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` — the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) — this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three — + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) — the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) — the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** — for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from — it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding — see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) — don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login — detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) — `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it — + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" — don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` — the same base class `AuthCodeFlow`/`ImplicitFlow` derive — is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties — no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names — the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced — match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time — + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data — matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet — +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login — so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency — not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created — the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in — and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) — not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet — point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so — that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Web._Blank/.claude/skills/nano-add-authentication-jwt/SKILL.md b/Web._Blank/.claude/skills/nano-add-authentication-jwt/SKILL.md index ad8fcca6..d6fdce14 100644 --- a/Web._Blank/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Web._Blank/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are — there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` — this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) — these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` — that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is — this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider — its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are — there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing — the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) — issuer app only diff --git a/Web._Blank/.claude/skills/nano-add-entity/SKILL.md b/Web._Blank/.claude/skills/nano-add-entity/SKILL.md index d9f45740..46b8a48c 100644 --- a/Web._Blank/.claude/skills/nano-add-entity/SKILL.md +++ b/Web._Blank/.claude/skills/nano-add-entity/SKILL.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Web._Blank/.claude/skills/nano-remove-authentication-external-custom/SKILL.md b/Web._Blank/.claude/skills/nano-remove-authentication-external-custom/SKILL.md new file mode 100644 index 00000000..ff7ea57d --- /dev/null +++ b/Web._Blank/.claude/skills/nano-remove-authentication-external-custom/SKILL.md @@ -0,0 +1,81 @@ +--- +name: nano-remove-authentication-external-custom +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working — not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` — the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central — find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` — it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too — **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) — a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any — none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with — removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase — an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + — sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down — the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above — any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails — the frontend piece needs removing separately. diff --git a/Web._Blank/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Web._Blank/.github/prompts/nano-add-authentication-external-custom.prompt.md new file mode 100644 index 00000000..370f29d1 --- /dev/null +++ b/Web._Blank/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -0,0 +1,216 @@ +--- +mode: agent +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +--- + +# Nano add custom external authentication + +Scaffolds a `BaseAuthExternalRepository` implementation on an existing Nano API/Web +application, for an external login provider Nano has no built-in support for. Read AGENTS.md's +`#### Authentication` section first, especially `##### Custom external provider` - it documents +`TFlow`, the two abstract methods, and the auto-discovery mechanism; this skill does not repeat +that, only how to apply it and what it wires up as a result. + +**Prerequisite: `Jwt` and the `AuthController` must already be configured.** A repository class on +its own does nothing without something to expose it - if the app has no `Jwt` config or +`AuthController` yet, stop and point the user at `nano-add-authentication-jwt` first. That skill's +own "Custom provider" note under External Login points back here for the actual scaffold - the two +are meant to be run in that order, not duplicated against each other. + +**No `Program.cs` registration.** Like every `BaseAuthExternalRepository` implementation, +the generated class is discovered by type - nothing to add to `.ConfigureServices(...)`. + +**Output is a placeholder shell, not a working integration.** This skill cannot know a specific +provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns +static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will +build and the new endpoint(s) will respond, but signing in through them won't yet produce a real +user identity from any actual external service. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, and for an existing `AuthController`. If either is missing, stop + - see the prerequisite above. +2. **What should the provider be called?** This is `ProviderName` - the constructor's string + argument, and the literal path segment in every route this exposes + (`/auth/login/external/{providerName}/...`, lowercased automatically at registration time + regardless of the casing used here). Ask; don't default to the class name. Pick something that + reads naturally in a URL (e.g. `Auth0`, `Okta`, `CompanySso`) - this is user-facing in the API + surface, not an internal identifier. +3. **Which flow shape does the provider actually need?** Ask; don't guess. Present all three - + don't just offer the two built-in ones and treat a custom shape as a dead end: + - **`AuthCodeFlow`** (`Code`/`CodeVerifier`/`RedirectUri`) - the provider exchanges an + authorization code for tokens server-side, the same shape Nano's own built-in Google/Microsoft + providers use. Pick this if the provider does a real OAuth authorization-code redirect with + PKCE. + - **`ImplicitFlow`** (`AccessToken`) - the frontend obtains an access token directly (e.g. via + the provider's own client-side SDK) and hands it straight to the backend to validate, the same + shape Nano's built-in Facebook provider uses. Pick this if there's no server-side code + exchange at all, just a token the frontend already has. + - **A custom class the user derives from `BaseAuthFlow` themselves** - for when the provider's + handshake carries different or additional data than either built-in shape (e.g. an auth code + plus a tenant/organization identifier the provider requires alongside it). `BaseAuthFlow` is + the abstract base both `AuthCodeFlow` and `ImplicitFlow` derive from - it's a bare marker with + no members of its own, so deriving from it needs nothing beyond adding whatever properties the + provider's own handshake sends. Say the base class name explicitly when presenting this option, + since the user needs it to know what they're deriving from (or to have this skill scaffold it + for them, see "Custom flow class" below). + `AuthCodeFlow`/`ImplicitFlow`/a custom subclass all derive `BaseAuthFlow`. If the user picks + custom, get the class name from them before scaffolding - see "Custom flow class" below. +4. **Does the provider support refreshing a login without the user re-authenticating?** Ask. + This decides how `AuthenticateRefreshAsync` is scaffolded (see below) - don't default to "yes" + just because the method has to exist. +5. **Persistent or transient login - detect it, don't ask.** Check whether `Data:Identity` is + configured in the app's base `appsettings.json`: + - **Not configured (transient)**: the new provider is reachable at + `POST /auth/login/external/{providerName}/transient` (+ `/transient/refresh`), via the + existing `AuthController`, per the `!hasIdentity && hasAuthController` gate documented in + AGENTS.md's Authentication section. Nothing further to check. + - **Configured (persistent)**: also check whether a `BaseEntityUserController`-derived class + exists for the identity-backed user entity. + - If one exists, the new provider becomes reachable at + `POST /{userTag}s/signup/external/{providerName}` (sign up a new user via this provider) and + `POST /{userTag}s/{id}/external-logins/add/{providerName}` / + `POST|DELETE /{userTag}s/{id}/external-logins/remove/{providerName}` (link/unlink it on an + existing authenticated user) - `{userTag}` being that entity's pluralized name. + - If no such controller exists yet, say so plainly: the repository class will still compile + and register, but nothing currently exposes a way to sign up or link a login through it - + point the user at whatever skill scaffolds their identity-backed user controller before + calling this done. + - Separately, since `AuthController` also exists (checked in step 1), the provider is + additionally reachable at `POST /auth/login/external/{providerName}` for signing an + *existing*, already-linked user back in. + Report the actual resulting endpoint set back to the user in "After making the change" - don't + leave it implicit. + +## Custom flow class (only if step 3 picked it) + +`BaseAuthFlow` - the same base class `AuthCodeFlow`/`ImplicitFlow` derive - is a bare marker class +with no members of its own. Dispatch to the right repository/method overload happens entirely +through the actual CLR type of `TFlow` (`BaseAuthExternalRepository.AuthenticateAsync`'s +`flow is not TFlow typedFlow` check, and each generated endpoint's request model being generically +typed to one concrete `TFlow`), so a custom subclass needs nothing beyond deriving `BaseAuthFlow` and +adding its own properties - no fixed value or constructor argument to satisfy. + +Conventionally alongside the repository class, e.g. `Authentication/{Provider}Flow.cs`: + +```csharp +public class {Provider}Flow : BaseAuthFlow +{ + // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, + // each `[Required] public virtual required {Type} {Name} { get; set; }`. +} +``` + +Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. +Since this skill doesn't know what properties the provider's flow actually needs, leave the class +body as the `// TODO` above rather than guessing field names - the user fills those in once they +know exactly what the provider's handshake sends. + +## The repository class + +Conventionally `Authentication/{Provider}ExternalRepository.cs` in the main application project +(discovery is by type, so the location isn't enforced - match this app's existing folder +conventions if it already has an `Authentication/`-equivalent folder for anything else). + +```csharp +public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow}>("{ProviderName}") +{ + public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for + // ImplicitFlow, or whatever properties a custom flow class carries), and map its + // response onto ExternalAuthenticationData below. + await Task.CompletedTask; + + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "johndoe@domain.com", + PhoneNumber = "+4520111112", + Name = "John Doe", + ExternalToken = new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + } + // TransientClaims: non-persisted claims merged into the issued JWT at login time - + // this is the right place to map trusted data from the external provider's own + // response (a role, a group membership) onto a claim, since it comes from + // AuthenticateAsync's own trusted return value, not from caller input. Leave empty + // unless the provider's response actually carries something worth asserting. + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // response onto ExternalAuthenticationToken below. + await Task.CompletedTask; + + return new ExternalAuthenticationToken + { + Name = this.ProviderName, + Token = "token", + RefreshToken = "refresh-token" + }; + } +} +``` + +**If step 4 found the provider doesn't support refresh**, scaffold `AuthenticateRefreshAsync` to +throw instead of returning fake-but-plausible token data - matching `AuthExternalFacebookRepository`'s +own real, shipped behavior for exactly this case: + +```csharp + public override Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + => throw new UnauthorizedException(); +``` + +A placeholder that always "succeeds" is worse than one that's honestly not implemented yet - +returning mock data here would make a caller relying on refresh silently believe it works. + +⚠ **`AuthenticateRefreshAsync` isn't only reachable through the transient refresh route.** +`BaseAuthIdentityRepository`'s own `/auth/login/refresh` also calls back into it, whenever the JWT +being refreshed carries an external-provider refresh-token claim from its original login - so this +method matters in **both** persistent and transient mode, not just transient. Don't assume it's +dead code just because the app is persistent. + +## Wiring real provider credentials later + +Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +for real. At that point the provider will need its own credentials (an API key, a client +id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom +Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, +injected into the repository like any other constructor dependency - not routed through +`Jwt.ExternalLogins`, which is exclusively for the three built-in providers. For Staging/Production +secret storage, this is closer to how Facebook/Google are handled than Microsoft: there's no +CLI-scriptable credential setup for an arbitrary custom provider, so ask the user how they want +the real values stored (a Kubernetes secret following this app's existing `.kubernetes/*-secret.yaml` +pattern is the common answer) rather than assuming a convention. + +## After making the change + +- Show the file(s) created - the repository class, and the custom flow class too if step 3 needed + one. +- State the flow type chosen and why (matching what the user said about the provider's real OAuth + shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + for the user to fill in - and the refresh decision (implemented for real later vs. explicitly + unsupported). +- **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's + full set (signup/add/remove on the user controller, plus login on `AuthController`) - not a + generic restatement of what's theoretically possible. If step 5 found persistent mode with no + user controller yet, repeat that gap explicitly rather than letting the response imply everything + is reachable. +- Restate plainly that this is a placeholder: the class compiles and the endpoints respond, but + `AuthenticateAsync`/`AuthenticateRefreshAsync` don't call the real provider yet - point at + "Wiring real provider credentials later" above as the next step, and don't describe the feature + as done/working end-to-end. +- If the frontend needs to redirect through the provider's own sign-in (AuthCodeFlow) or obtain a + token via its SDK (ImplicitFlow) first, say so - that half is outside this skill and Nano + entirely, same caveat AGENTS.md gives for the built-in providers. diff --git a/Web._Blank/.github/prompts/nano-add-authentication-jwt.prompt.md b/Web._Blank/.github/prompts/nano-add-authentication-jwt.prompt.md index 32387d4a..8746a5fa 100644 --- a/Web._Blank/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Web._Blank/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -237,53 +237,13 @@ value only where it's actually safe to have one. refresh support also needs the frontend's authorize request to include `access_type=offline`/ `prompt=consent`, which has nothing to do with this `Scopes` entry either. -**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, -this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in -providers are - there's no appsettings.json entry for it at all. - -1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever - matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom - `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. -2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application - project (discovery is by type, so the location isn't enforced): - ```csharp - public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") - { - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) - { - // call the external provider, map its response to ExternalAuthenticationData - return new ExternalAuthenticationData - { - Id = "external-id", - Username = "MyUser", - EmailAddress = "user@domain.com", - Name = "My User", - ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } - }; - } - - public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) - { - // refresh against the external provider - return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; - } - } - ``` - The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what - `AuthExternalRepositoryAggregator` resolves against, and what appears in the - `/auth/login/external/{providerName}/...` route, so ask the user what they want it called - rather than defaulting to the class name. -3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these - are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options - class bound from whatever config section makes sense for this provider (same pattern as any - other custom service in this codebase), then inject it into the repository's constructor. Don't - try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three - built-in providers. - -Either way, the actual login endpoint this exposes is -`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the -persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold -that call site, only the repository/config that backs it. +**Custom provider - its own skill, `nano-add-authentication-external-custom`.** Per AGENTS.md's +`##### Custom external provider`, a custom provider is auto-discovered by type, not registered via +`Jwt.ExternalLogins` config the way built-in providers are - there's no appsettings.json entry for +it at all, just a `BaseAuthExternalRepository` subclass. That skill covers the `TFlow` +choice (`AuthCodeFlow` vs. `ImplicitFlow`), the class scaffold itself, and reports which endpoint +set (transient vs. persistent) the new provider ends up exposing - the same distinction as +`nano-add-authentication-microsoft` above, use that skill instead of hand-rolling the class here. ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only diff --git a/Web._Blank/.github/prompts/nano-add-entity.prompt.md b/Web._Blank/.github/prompts/nano-add-entity.prompt.md index f8345ca6..71b15597 100644 --- a/Web._Blank/.github/prompts/nano-add-entity.prompt.md +++ b/Web._Blank/.github/prompts/nano-add-entity.prompt.md @@ -127,6 +127,11 @@ public class : BaseEntity `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 + 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 + 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`, `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" @@ -264,7 +269,7 @@ public class QueryCriteria : BaseQueryCriteria if (!string.IsNullOrEmpty(this.Name)) { expression - .StartsWith("Name", this.Name); + .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } expressions @@ -281,6 +286,10 @@ public class QueryCriteria : BaseQueryCriteria - **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 + `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, diff --git a/Web._Blank/.github/prompts/nano-remove-authentication-external-custom.prompt.md b/Web._Blank/.github/prompts/nano-remove-authentication-external-custom.prompt.md new file mode 100644 index 00000000..2ea8db3d --- /dev/null +++ b/Web._Blank/.github/prompts/nano-remove-authentication-external-custom.prompt.md @@ -0,0 +1,81 @@ +--- +mode: agent +description: Remove a custom BaseAuthExternalRepository external-login provider (scaffolded by nano-add-authentication-external-custom) from a Nano.Library-based application, without touching JWT authentication, Identity, or any other configured provider. Use when the user asks to remove a custom/third-party/in-house external login provider from a Nano API or Web application while keeping regular login working - not for removing JWT authentication entirely (nano-remove-authentication-jwt), and not for a built-in provider (nano-remove-authentication-microsoft covers Microsoft; Facebook/Google are removed by hand per AGENTS.md). +--- + +# Nano remove custom external authentication + +Removes a custom external-login provider previously scaffolded by +`nano-add-authentication-external-custom` - the counterpart to that skill. Read it first; this one +undoes exactly what it adds. `Jwt`, the `AuthController`, Identity, and any other configured +provider are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes every configured external +provider (built-in or custom) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Which provider, specifically?** A `BaseAuthExternalRepository` class isn't named or + registered anywhere central - find it by its `ProviderName` (the constructor's string argument) + or by asking the user which class/file. If more than one custom provider exists in the app, + remove only the one named; don't assume "the custom provider" is singular. +2. **Where does the class live, and does it use a custom flow class?** Conventionally + `Authentication/{Provider}ExternalRepository.cs` in the main application project per + `nano-add-authentication-external-custom`'s own convention, but confirm by locating the actual + `BaseAuthExternalRepository` subclass with that `ProviderName` - it may have been moved or + renamed since scaffolding. Check its `TFlow` generic argument: if it's `AuthCodeFlow`/`ImplicitFlow` + there's nothing extra, but if it's a custom `BaseAuthFlow` subclass scaffolded alongside it (e.g. + `Authentication/{Provider}Flow.cs`), that file needs deleting too - **only if nothing else still + references it**. A custom flow class isn't necessarily this provider's exclusively; if another + repository also uses it, leave it in place and only remove the repository class being asked + about. +3. **Was this provider ever wired to real credentials?** Check whether an options class bound via + `AddNanoConfigSection` exists for it (per the "Wiring real provider credentials later" + note in the add-skill), and whether any Kubernetes secret / `.docker/.env` entries reference it. + If the class was still the unmodified placeholder shell (mocked return values, no real HTTP + calls), there's nothing beyond the class file itself to remove. +4. **Is this provider referenced by frontend sign-in code?** This skill only removes the backend + repository class (and its config/secrets, if any) - a frontend sign-in button or redirect flow + built against this provider isn't something this skill can find or remove. + +## Removing the class + +Delete the `{Provider}ExternalRepository.cs` file (and its matching `.Designer`-equivalent, if +any - none exists for this kind of class, unlike an EF migration), plus the custom flow class file +too if step 2 found one that's exclusively this provider's. No `Program.cs` change is needed +either way, since neither class was ever registered there to begin with - removal is simply +deleting the file(s). + +## Removing real credentials, if step 3 found any + +Only if the provider had actually been wired up past the placeholder stage: + +- Remove the options class and its `AddNanoConfigSection(...)` call from + `ConfigureServices(...)`, if one was added. +- Remove the matching section from every `appsettings*.json` file it was added to. +- Remove the corresponding keys from `.docker/.env`, if present. +- If a Kubernetes secret was created for this provider specifically (not shared with anything + else), delete its `.kubernetes/*.yaml` file, remove its apply line from the `Kubernetes Deploy` + workflow step, remove its `App__...` entries from `.kubernetes/deployment.yaml`'s container + `env`, and remove its `.kubernetes\*.yaml = .kubernetes\*.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block. + +⚠ This does **not** delete any credential that lives outside this codebase - an API key or client +secret registered directly with the external provider's own developer console/dashboard stays +valid there until revoked by hand. Say this explicitly rather than letting "removed the config" +read as "the credential is dead." + +## After making the change + +- Show every file touched/deleted, including the custom flow class if one was removed. +- Confirm JWT authentication (and any other configured provider, built-in or custom) is unaffected + - sign-in through every other path keeps working exactly as before, only this one provider + disappears. +- State plainly which endpoint(s) this took down - the transient `/auth/login/external/{providerName}/...` + pair, or the persistent signup/add/remove/login set, per whichever mode + `nano-add-authentication-external-custom` had reported when this was added. +- If step 3 found real credentials wired up, restate the ⚠ above - any external-provider-side + registration/API key isn't revoked by this skill, only this app's own reference to it. +- If step 4 found frontend sign-in code, remind the user that removing the backend alone leaves a + sign-in button that now fails - the frontend piece needs removing separately. diff --git a/Web._Blank/AGENTS.md b/Web._Blank/AGENTS.md index 3805edf2..df7d8c25 100644 --- a/Web._Blank/AGENTS.md +++ b/Web._Blank/AGENTS.md @@ -1792,10 +1792,20 @@ public class MyExternalRepository() : BaseAuthExternalRepository(" ``` `TFlow` is `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) — pick whichever matches the provider's -OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. Every `IAuthExternalRepository` -implementation is exposed through `AuthExternalRepositoryAggregator`, which resolves the right one by -`ProviderName` when multiple are registered. Built-in providers (Facebook/Google/Microsoft) exist purely as -config (see above) — no repository implementation needed for those. +OAuth flow, or derive your own from `BaseAuthFlow` for something else entirely. `BaseAuthFlow` is a bare marker +base with no members of its own — a custom subclass just adds whatever properties the provider's real handshake +sends, nothing else to satisfy. Every `IAuthExternalRepository` implementation is exposed through +`AuthExternalRepositoryAggregator`, which resolves the right one by `ProviderName` when multiple are registered +— dispatch happens entirely through `TFlow`'s actual CLR type (`BaseAuthExternalRepository. +AuthenticateAsync`'s `flow is not TFlow typedFlow` check, and each generated endpoint's request model being +generically typed to one concrete `TFlow`), so a custom flow class needs nothing beyond deriving `BaseAuthFlow` +to be dispatched to correctly. Built-in providers (Facebook/Google/Microsoft) exist purely as config (see +above) — no repository implementation needed for those. + +Use the `nano-add-authentication-external-custom` skill to scaffold this — it asks which `TFlow` fits the +provider (`ImplicitFlow`, `AuthCodeFlow`, or a custom `BaseAuthFlow` subclass it can scaffold too) and what +to name it, and reports whether the resulting login ends up transient or persistent (see below) based on +whether Identity is configured. `nano-remove-authentication-external-custom` removes one. ##### Access tokens & claims @@ -2016,6 +2026,68 @@ public class MyEntityQueryCriteria : BaseQueryCriteria Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. +##### Case-insensitive search (Normalized columns) + +`StartsWith`/`Contains`/`Equal` against a raw `string` column relies on the database's own collation to decide +case-sensitivity — which isn't guaranteed consistent across [Data Providers](#data-providers) (MySQL's default +collation is case-insensitive, PostgreSQL's and SqlServer's default to case-sensitive), and isn't something a +query criteria class should depend on implicitly either way. For any `string` property a query criteria filters +by for **human-typed search or lookup** — a name, a domain, a keyword field, anything a person might type in +mixed case and expect to still match — give it a computed `XNormalized` twin instead of matching the raw column +directly: + +```csharp +public class MyEntity : BaseEntity +{ + public virtual string Name + { + get; + set + { + field = value; + this.NameNormalized = value.ToUpper(); + } + } = null!; + + public virtual string NameNormalized { get; internal set; } = null!; +} +``` + +```csharp +public class MyEntityMapping : BaseEntityMapping +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(128); + builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); + + // Index (and IsUnique(), if the raw property needed uniqueness) goes on the Normalized twin, not Name: + builder.HasIndex(x => x.NameNormalized); + } +} +``` + +```csharp +if (!string.IsNullOrEmpty(this.Name)) +{ + expression.StartsWith(nameof(MyEntity.NameNormalized), this.Name.ToUpper()); +} +``` + +- `Name` keeps whatever casing the caller typed — it's still what gets displayed back. `NameNormalized` exists + purely so search/lookup and uniqueness are correct regardless of collation, and is never itself exposed for + editing (`internal set`, populated only by `Name`'s own setter) or included in a `[Publish]` list. +- If the raw property carried a unique index (a domain, a plan/category name meant to be one-of-a-kind), move + that `IsUnique()` onto the `Normalized` twin too — two values differing only by case should conflict as + duplicates, the same way the search should find them as the same match. +- **Don't apply this to every `string` property reflexively.** It's for properties a query criteria actually + matches with `StartsWith`/`Contains`/`Equal` where case is not meant to be significant. Skip it for an exact + technical identifier where case either doesn't vary in practice (a numeric code) or is already canonicalized + at the point of entry, and for an internal fixed-constant lookup (matching a seeded system name by its exact + literal) where case sensitivity is the intended, correct behavior. + ##### Available operations Every `CriteriaExpression` method takes `(string property, TType value, LogicalType logicalType = And)` (or two From 030773b11e45a726340021d4713140fc63bb32a6 Mon Sep 17 00:00:00 2001 From: vivet Date: Wed, 30 Sep 2026 19:28:59 +0200 Subject: [PATCH 11/13] Updated --- .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Api.ApiClients.Audit/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Api.ApiClients.Entity/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Api.ApiClients.RootLogIn/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Api.ApiClients/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Api.Auth.External.Custom/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Api.Auth.External.Microsoft/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Api.Auth.RootLogin/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Api.Authorization/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Api.ContentNegotiation/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Api.Cookies/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Api.CustomConfigSection/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Api.CustomMiddleware/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Api.CustomService/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Api.Data.Audit/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Api.Data.EntityEvents/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Api.Data.Identity.Auth.ApiKey/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Api.Data.Identity.Auth.External.Custom/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Api.Data.Identity.Auth.Jwt/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Api.Data.Identity/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Api.Data.InMemory/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Api.Data.LazyLoading/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Api.Data.MySql.Collation/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Api.Data.MySql.Mappings/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Api.Data.MySql.Spatial/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Api.Data.MySql.StoredProcedures/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Api.Data.MySql.Views/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Api.Data.MySql/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Api.Data.PostgreSQL.Spatial/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Api.Data.PostgreSQL/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Api.Data.Repository.AutoSave/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Api.Data.Repository.Includes/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Api.Data.SoftDelete/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Api.Data.SqLite/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Api.Data.SqlServer.Spatial/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Api.Data.SqlServer/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Api.Data.Triggers/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Api.Documentation.Csp/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Api.Documentation/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Api.ErrorHandling/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Api.Eventing.RabbitMq/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Api.HealthChecks/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Api.Hosting.Http/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Api.Hosting.Https/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Api.Hosting.MultipartLimits/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Api.Localization/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Api.Logging.Log4Net/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Api.Logging.Microsoft/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Api.Logging.NLog/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Api.Logging.Serilog/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Api.Metrics/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Api.MultipartJson/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ .../AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Api.PolicyHeaders.ContentTypeOptions/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Api.PolicyHeaders.Cors/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Api.PolicyHeaders.ForwardedHeaders/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Api.PolicyHeaders.FrameOptions/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Api.PolicyHeaders.Hsts/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Api.PolicyHeaders.ReferrerPolicy/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Api.PolicyHeaders.Robots/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Api.PolicyHeaders.XssProtection/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Api.RequestTracing/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Api.ResponseCache/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Api.ResponseCompression/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Api.Session/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Api.StartupTasks/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Api.StaticFiles/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Api.Storage.Azure/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Api.Storage.Local/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Api.TimeZone/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Api.Versioning/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Api.VirusScan/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Api._Blank/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Console.CustomConfigSection/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Console.CustomService/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Console.Data.InMemory/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Console.Data.MySql/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Console.Data.PostgreSQL/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Console.Data.SqLite/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Console.Data.SqlServer/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Console.Eventing.RabbitMq/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Console.ExceptionHandling/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Console.Localization/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Console.Logging.Log4Net/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Console.Logging.Microsoft/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Console.Logging.NLog/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Console.Logging.Serilog/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Console.StartupTasks/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Console.Storage.Azure/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Console.Storage.Local/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Console.Workers/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Console._Blank/AGENTS.md | 9 +++++++++ .../SKILL.md | 16 ++++++++-------- .../.claude/skills/nano-add-entity/SKILL.md | 10 ++++++++++ .../skills/nano-add-postman-collection/SKILL.md | 7 +++++++ ...-add-authentication-external-custom.prompt.md | 16 ++++++++-------- .../.github/prompts/nano-add-entity.prompt.md | 10 ++++++++++ Web._Blank/AGENTS.md | 9 +++++++++ 552 files changed, 4784 insertions(+), 1472 deletions(-) diff --git a/Api.ApiClients.Audit/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.ApiClients.Audit/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Api.ApiClients.Audit/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Api.ApiClients.Audit/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's 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 46b8a48c..cb94fd63 100644 --- a/Api.ApiClients.Audit/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.ApiClients.Audit/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 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 @@ -163,6 +163,13 @@ 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`/ diff --git a/Api.ApiClients.Audit/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.ApiClients.Audit/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Api.ApiClients.Audit/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Api.ApiClients.Audit/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's 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 71b15597..f31fd49d 100644 --- a/Api.ApiClients.Audit/.github/prompts/nano-add-entity.prompt.md +++ b/Api.ApiClients.Audit/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Api.ApiClients.Audit/AGENTS.md b/Api.ApiClients.Audit/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Api.ApiClients.Audit/AGENTS.md +++ b/Api.ApiClients.Audit/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Api.ApiClients.Entity/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.ApiClients.Entity/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Api.ApiClients.Entity/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Api.ApiClients.Entity/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's 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 46b8a48c..cb94fd63 100644 --- a/Api.ApiClients.Entity/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.ApiClients.Entity/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 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 @@ -163,6 +163,13 @@ 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`/ diff --git a/Api.ApiClients.Entity/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.ApiClients.Entity/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Api.ApiClients.Entity/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Api.ApiClients.Entity/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's 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 71b15597..f31fd49d 100644 --- a/Api.ApiClients.Entity/.github/prompts/nano-add-entity.prompt.md +++ b/Api.ApiClients.Entity/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Api.ApiClients.Entity/AGENTS.md b/Api.ApiClients.Entity/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Api.ApiClients.Entity/AGENTS.md +++ b/Api.ApiClients.Entity/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Api.ApiClients.RootLogIn/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.ApiClients.RootLogIn/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Api.ApiClients.RootLogIn/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Api.ApiClients.RootLogIn/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's 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 46b8a48c..cb94fd63 100644 --- a/Api.ApiClients.RootLogIn/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.ApiClients.RootLogIn/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 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 @@ -163,6 +163,13 @@ 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`/ diff --git a/Api.ApiClients.RootLogIn/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Api.ApiClients.RootLogIn/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's 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 71b15597..f31fd49d 100644 --- a/Api.ApiClients.RootLogIn/.github/prompts/nano-add-entity.prompt.md +++ b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Api.ApiClients.RootLogIn/AGENTS.md b/Api.ApiClients.RootLogIn/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Api.ApiClients.RootLogIn/AGENTS.md +++ b/Api.ApiClients.RootLogIn/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Api.ApiClients/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.ApiClients/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Api.ApiClients/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Api.ApiClients/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's diff --git a/Api.ApiClients/.claude/skills/nano-add-entity/SKILL.md b/Api.ApiClients/.claude/skills/nano-add-entity/SKILL.md index 46b8a48c..cb94fd63 100644 --- a/Api.ApiClients/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.ApiClients/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 100644 --- a/Api.ApiClients/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.ApiClients/.claude/skills/nano-add-postman-collection/SKILL.md @@ -163,6 +163,13 @@ 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`/ diff --git a/Api.ApiClients/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.ApiClients/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Api.ApiClients/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Api.ApiClients/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's diff --git a/Api.ApiClients/.github/prompts/nano-add-entity.prompt.md b/Api.ApiClients/.github/prompts/nano-add-entity.prompt.md index 71b15597..f31fd49d 100644 --- a/Api.ApiClients/.github/prompts/nano-add-entity.prompt.md +++ b/Api.ApiClients/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Api.ApiClients/AGENTS.md b/Api.ApiClients/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Api.ApiClients/AGENTS.md +++ b/Api.ApiClients/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Api.Auth.External.Custom/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Auth.External.Custom/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Api.Auth.External.Custom/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Api.Auth.External.Custom/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's 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 46b8a48c..cb94fd63 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 @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 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 @@ -163,6 +163,13 @@ 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`/ diff --git a/Api.Auth.External.Custom/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Auth.External.Custom/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Api.Auth.External.Custom/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Api.Auth.External.Custom/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's 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 71b15597..f31fd49d 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 @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Api.Auth.External.Custom/AGENTS.md b/Api.Auth.External.Custom/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Api.Auth.External.Custom/AGENTS.md +++ b/Api.Auth.External.Custom/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Api.Auth.External.Microsoft/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Auth.External.Microsoft/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Api.Auth.External.Microsoft/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Api.Auth.External.Microsoft/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's 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 46b8a48c..cb94fd63 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 @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 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 @@ -163,6 +163,13 @@ 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`/ diff --git a/Api.Auth.External.Microsoft/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Auth.External.Microsoft/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Api.Auth.External.Microsoft/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Api.Auth.External.Microsoft/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's 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 71b15597..f31fd49d 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 @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Api.Auth.External.Microsoft/AGENTS.md b/Api.Auth.External.Microsoft/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Api.Auth.External.Microsoft/AGENTS.md +++ b/Api.Auth.External.Microsoft/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Api.Auth.RootLogin/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Auth.RootLogin/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Api.Auth.RootLogin/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Api.Auth.RootLogin/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's 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 46b8a48c..cb94fd63 100644 --- a/Api.Auth.RootLogin/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Auth.RootLogin/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 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 @@ -163,6 +163,13 @@ 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`/ diff --git a/Api.Auth.RootLogin/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Auth.RootLogin/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Api.Auth.RootLogin/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Api.Auth.RootLogin/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's 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 71b15597..f31fd49d 100644 --- a/Api.Auth.RootLogin/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Auth.RootLogin/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Api.Auth.RootLogin/AGENTS.md b/Api.Auth.RootLogin/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Api.Auth.RootLogin/AGENTS.md +++ b/Api.Auth.RootLogin/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Api.Authorization/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Authorization/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Api.Authorization/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Api.Authorization/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's diff --git a/Api.Authorization/.claude/skills/nano-add-entity/SKILL.md b/Api.Authorization/.claude/skills/nano-add-entity/SKILL.md index 46b8a48c..cb94fd63 100644 --- a/Api.Authorization/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Authorization/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 100644 --- a/Api.Authorization/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Authorization/.claude/skills/nano-add-postman-collection/SKILL.md @@ -163,6 +163,13 @@ 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`/ diff --git a/Api.Authorization/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Authorization/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Api.Authorization/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Api.Authorization/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's diff --git a/Api.Authorization/.github/prompts/nano-add-entity.prompt.md b/Api.Authorization/.github/prompts/nano-add-entity.prompt.md index 71b15597..f31fd49d 100644 --- a/Api.Authorization/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Authorization/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Api.Authorization/AGENTS.md b/Api.Authorization/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Api.Authorization/AGENTS.md +++ b/Api.Authorization/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Api.ContentNegotiation/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.ContentNegotiation/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Api.ContentNegotiation/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Api.ContentNegotiation/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's diff --git a/Api.ContentNegotiation/.claude/skills/nano-add-entity/SKILL.md b/Api.ContentNegotiation/.claude/skills/nano-add-entity/SKILL.md index 46b8a48c..cb94fd63 100644 --- a/Api.ContentNegotiation/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.ContentNegotiation/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 100644 --- a/Api.ContentNegotiation/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.ContentNegotiation/.claude/skills/nano-add-postman-collection/SKILL.md @@ -163,6 +163,13 @@ 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`/ diff --git a/Api.ContentNegotiation/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.ContentNegotiation/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Api.ContentNegotiation/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Api.ContentNegotiation/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's diff --git a/Api.ContentNegotiation/.github/prompts/nano-add-entity.prompt.md b/Api.ContentNegotiation/.github/prompts/nano-add-entity.prompt.md index 71b15597..f31fd49d 100644 --- a/Api.ContentNegotiation/.github/prompts/nano-add-entity.prompt.md +++ b/Api.ContentNegotiation/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Api.ContentNegotiation/AGENTS.md b/Api.ContentNegotiation/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Api.ContentNegotiation/AGENTS.md +++ b/Api.ContentNegotiation/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Api.Cookies/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Cookies/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Api.Cookies/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Api.Cookies/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's diff --git a/Api.Cookies/.claude/skills/nano-add-entity/SKILL.md b/Api.Cookies/.claude/skills/nano-add-entity/SKILL.md index 46b8a48c..cb94fd63 100644 --- a/Api.Cookies/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Cookies/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 100644 --- a/Api.Cookies/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Cookies/.claude/skills/nano-add-postman-collection/SKILL.md @@ -163,6 +163,13 @@ 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`/ diff --git a/Api.Cookies/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Cookies/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Api.Cookies/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Api.Cookies/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's diff --git a/Api.Cookies/.github/prompts/nano-add-entity.prompt.md b/Api.Cookies/.github/prompts/nano-add-entity.prompt.md index 71b15597..f31fd49d 100644 --- a/Api.Cookies/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Cookies/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Api.Cookies/AGENTS.md b/Api.Cookies/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Api.Cookies/AGENTS.md +++ b/Api.Cookies/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Api.CustomConfigSection/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.CustomConfigSection/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Api.CustomConfigSection/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Api.CustomConfigSection/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's diff --git a/Api.CustomConfigSection/.claude/skills/nano-add-entity/SKILL.md b/Api.CustomConfigSection/.claude/skills/nano-add-entity/SKILL.md index 46b8a48c..cb94fd63 100644 --- a/Api.CustomConfigSection/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.CustomConfigSection/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 100644 --- a/Api.CustomConfigSection/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.CustomConfigSection/.claude/skills/nano-add-postman-collection/SKILL.md @@ -163,6 +163,13 @@ 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`/ diff --git a/Api.CustomConfigSection/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.CustomConfigSection/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Api.CustomConfigSection/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Api.CustomConfigSection/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's diff --git a/Api.CustomConfigSection/.github/prompts/nano-add-entity.prompt.md b/Api.CustomConfigSection/.github/prompts/nano-add-entity.prompt.md index 71b15597..f31fd49d 100644 --- a/Api.CustomConfigSection/.github/prompts/nano-add-entity.prompt.md +++ b/Api.CustomConfigSection/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Api.CustomConfigSection/AGENTS.md b/Api.CustomConfigSection/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Api.CustomConfigSection/AGENTS.md +++ b/Api.CustomConfigSection/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Api.CustomMiddleware/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.CustomMiddleware/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Api.CustomMiddleware/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Api.CustomMiddleware/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's diff --git a/Api.CustomMiddleware/.claude/skills/nano-add-entity/SKILL.md b/Api.CustomMiddleware/.claude/skills/nano-add-entity/SKILL.md index 46b8a48c..cb94fd63 100644 --- a/Api.CustomMiddleware/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.CustomMiddleware/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 100644 --- a/Api.CustomMiddleware/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.CustomMiddleware/.claude/skills/nano-add-postman-collection/SKILL.md @@ -163,6 +163,13 @@ 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`/ diff --git a/Api.CustomMiddleware/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.CustomMiddleware/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Api.CustomMiddleware/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Api.CustomMiddleware/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's diff --git a/Api.CustomMiddleware/.github/prompts/nano-add-entity.prompt.md b/Api.CustomMiddleware/.github/prompts/nano-add-entity.prompt.md index 71b15597..f31fd49d 100644 --- a/Api.CustomMiddleware/.github/prompts/nano-add-entity.prompt.md +++ b/Api.CustomMiddleware/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Api.CustomMiddleware/AGENTS.md b/Api.CustomMiddleware/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Api.CustomMiddleware/AGENTS.md +++ b/Api.CustomMiddleware/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Api.CustomService/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.CustomService/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Api.CustomService/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Api.CustomService/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's diff --git a/Api.CustomService/.claude/skills/nano-add-entity/SKILL.md b/Api.CustomService/.claude/skills/nano-add-entity/SKILL.md index 46b8a48c..cb94fd63 100644 --- a/Api.CustomService/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.CustomService/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 100644 --- a/Api.CustomService/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.CustomService/.claude/skills/nano-add-postman-collection/SKILL.md @@ -163,6 +163,13 @@ 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`/ diff --git a/Api.CustomService/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.CustomService/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Api.CustomService/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Api.CustomService/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's diff --git a/Api.CustomService/.github/prompts/nano-add-entity.prompt.md b/Api.CustomService/.github/prompts/nano-add-entity.prompt.md index 71b15597..f31fd49d 100644 --- a/Api.CustomService/.github/prompts/nano-add-entity.prompt.md +++ b/Api.CustomService/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Api.CustomService/AGENTS.md b/Api.CustomService/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Api.CustomService/AGENTS.md +++ b/Api.CustomService/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Api.Data.Audit/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Data.Audit/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Api.Data.Audit/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Api.Data.Audit/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's 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 46b8a48c..cb94fd63 100644 --- a/Api.Data.Audit/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.Audit/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 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 @@ -163,6 +163,13 @@ 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`/ diff --git a/Api.Data.Audit/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Data.Audit/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Api.Data.Audit/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Api.Data.Audit/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's 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 71b15597..f31fd49d 100644 --- a/Api.Data.Audit/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.Audit/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Api.Data.Audit/AGENTS.md b/Api.Data.Audit/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Api.Data.Audit/AGENTS.md +++ b/Api.Data.Audit/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Api.Data.EntityEvents/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Data.EntityEvents/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Api.Data.EntityEvents/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Api.Data.EntityEvents/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's 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 46b8a48c..cb94fd63 100644 --- a/Api.Data.EntityEvents/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.EntityEvents/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 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 @@ -163,6 +163,13 @@ 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`/ diff --git a/Api.Data.EntityEvents/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Data.EntityEvents/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Api.Data.EntityEvents/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Api.Data.EntityEvents/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's 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 71b15597..f31fd49d 100644 --- a/Api.Data.EntityEvents/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.EntityEvents/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Api.Data.EntityEvents/AGENTS.md b/Api.Data.EntityEvents/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Api.Data.EntityEvents/AGENTS.md +++ b/Api.Data.EntityEvents/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's 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 46b8a48c..cb94fd63 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 @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 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 @@ -163,6 +163,13 @@ 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`/ diff --git a/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's 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 71b15597..f31fd49d 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 @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Api.Data.Identity.Auth.ApiKey/AGENTS.md b/Api.Data.Identity.Auth.ApiKey/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Api.Data.Identity.Auth.ApiKey/AGENTS.md +++ b/Api.Data.Identity.Auth.ApiKey/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's 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 46b8a48c..cb94fd63 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 @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 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 @@ -163,6 +163,13 @@ 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`/ diff --git a/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's 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 71b15597..f31fd49d 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 @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Api.Data.Identity.Auth.External.Custom/AGENTS.md b/Api.Data.Identity.Auth.External.Custom/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Api.Data.Identity.Auth.External.Custom/AGENTS.md +++ b/Api.Data.Identity.Auth.External.Custom/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's 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 46b8a48c..cb94fd63 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 @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 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 @@ -163,6 +163,13 @@ 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`/ diff --git a/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's 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 71b15597..f31fd49d 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 @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Api.Data.Identity.Auth.Jwt/AGENTS.md b/Api.Data.Identity.Auth.Jwt/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Api.Data.Identity.Auth.Jwt/AGENTS.md +++ b/Api.Data.Identity.Auth.Jwt/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Api.Data.Identity/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Data.Identity/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Api.Data.Identity/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Api.Data.Identity/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's 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 46b8a48c..cb94fd63 100644 --- a/Api.Data.Identity/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.Identity/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 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 @@ -163,6 +163,13 @@ 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`/ diff --git a/Api.Data.Identity/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Data.Identity/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Api.Data.Identity/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Api.Data.Identity/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's 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 71b15597..f31fd49d 100644 --- a/Api.Data.Identity/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.Identity/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Api.Data.Identity/AGENTS.md b/Api.Data.Identity/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Api.Data.Identity/AGENTS.md +++ b/Api.Data.Identity/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Api.Data.InMemory/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Data.InMemory/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Api.Data.InMemory/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Api.Data.InMemory/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's 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 46b8a48c..cb94fd63 100644 --- a/Api.Data.InMemory/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.InMemory/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 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 @@ -163,6 +163,13 @@ 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`/ diff --git a/Api.Data.InMemory/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Data.InMemory/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Api.Data.InMemory/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Api.Data.InMemory/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's 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 71b15597..f31fd49d 100644 --- a/Api.Data.InMemory/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.InMemory/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Api.Data.InMemory/AGENTS.md b/Api.Data.InMemory/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Api.Data.InMemory/AGENTS.md +++ b/Api.Data.InMemory/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Api.Data.LazyLoading/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Data.LazyLoading/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Api.Data.LazyLoading/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Api.Data.LazyLoading/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's 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 46b8a48c..cb94fd63 100644 --- a/Api.Data.LazyLoading/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.LazyLoading/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 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 @@ -163,6 +163,13 @@ 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`/ diff --git a/Api.Data.LazyLoading/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Data.LazyLoading/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Api.Data.LazyLoading/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Api.Data.LazyLoading/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's 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 71b15597..f31fd49d 100644 --- a/Api.Data.LazyLoading/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.LazyLoading/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Api.Data.LazyLoading/AGENTS.md b/Api.Data.LazyLoading/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Api.Data.LazyLoading/AGENTS.md +++ b/Api.Data.LazyLoading/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Api.Data.MySql.Collation/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Data.MySql.Collation/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Api.Data.MySql.Collation/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Api.Data.MySql.Collation/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's 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 46b8a48c..cb94fd63 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 @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 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 @@ -163,6 +163,13 @@ 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`/ diff --git a/Api.Data.MySql.Collation/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Data.MySql.Collation/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Api.Data.MySql.Collation/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Api.Data.MySql.Collation/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's 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 71b15597..f31fd49d 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 @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Api.Data.MySql.Collation/AGENTS.md b/Api.Data.MySql.Collation/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Api.Data.MySql.Collation/AGENTS.md +++ b/Api.Data.MySql.Collation/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Api.Data.MySql.Mappings/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Data.MySql.Mappings/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Api.Data.MySql.Mappings/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Api.Data.MySql.Mappings/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's 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 46b8a48c..cb94fd63 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 @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 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 @@ -163,6 +163,13 @@ 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`/ diff --git a/Api.Data.MySql.Mappings/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Data.MySql.Mappings/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Api.Data.MySql.Mappings/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Api.Data.MySql.Mappings/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's 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 71b15597..f31fd49d 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 @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Api.Data.MySql.Mappings/AGENTS.md b/Api.Data.MySql.Mappings/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Api.Data.MySql.Mappings/AGENTS.md +++ b/Api.Data.MySql.Mappings/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Api.Data.MySql.Spatial/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Data.MySql.Spatial/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Api.Data.MySql.Spatial/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Api.Data.MySql.Spatial/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's 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 46b8a48c..cb94fd63 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 @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 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 @@ -163,6 +163,13 @@ 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`/ diff --git a/Api.Data.MySql.Spatial/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Data.MySql.Spatial/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Api.Data.MySql.Spatial/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Api.Data.MySql.Spatial/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's 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 71b15597..f31fd49d 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 @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Api.Data.MySql.Spatial/AGENTS.md b/Api.Data.MySql.Spatial/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Api.Data.MySql.Spatial/AGENTS.md +++ b/Api.Data.MySql.Spatial/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's 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 46b8a48c..cb94fd63 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 @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 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 @@ -163,6 +163,13 @@ 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`/ diff --git a/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's 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 71b15597..f31fd49d 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 @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Api.Data.MySql.StoredProcedures/AGENTS.md b/Api.Data.MySql.StoredProcedures/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Api.Data.MySql.StoredProcedures/AGENTS.md +++ b/Api.Data.MySql.StoredProcedures/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Api.Data.MySql.Views/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Data.MySql.Views/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Api.Data.MySql.Views/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Api.Data.MySql.Views/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's 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 46b8a48c..cb94fd63 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 @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 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 @@ -163,6 +163,13 @@ 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`/ diff --git a/Api.Data.MySql.Views/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Data.MySql.Views/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Api.Data.MySql.Views/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Api.Data.MySql.Views/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's 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 71b15597..f31fd49d 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 @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Api.Data.MySql.Views/AGENTS.md b/Api.Data.MySql.Views/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Api.Data.MySql.Views/AGENTS.md +++ b/Api.Data.MySql.Views/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Api.Data.MySql/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Data.MySql/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Api.Data.MySql/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Api.Data.MySql/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's 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 46b8a48c..cb94fd63 100644 --- a/Api.Data.MySql/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.MySql/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 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 @@ -163,6 +163,13 @@ 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`/ diff --git a/Api.Data.MySql/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Data.MySql/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Api.Data.MySql/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Api.Data.MySql/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's 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 71b15597..f31fd49d 100644 --- a/Api.Data.MySql/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.MySql/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Api.Data.MySql/AGENTS.md b/Api.Data.MySql/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Api.Data.MySql/AGENTS.md +++ b/Api.Data.MySql/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's 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 46b8a48c..cb94fd63 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 @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 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 @@ -163,6 +163,13 @@ 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`/ diff --git a/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's 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 71b15597..f31fd49d 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 @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Api.Data.PostgreSQL.Spatial/AGENTS.md b/Api.Data.PostgreSQL.Spatial/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Api.Data.PostgreSQL.Spatial/AGENTS.md +++ b/Api.Data.PostgreSQL.Spatial/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Api.Data.PostgreSQL/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Data.PostgreSQL/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Api.Data.PostgreSQL/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Api.Data.PostgreSQL/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's 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 46b8a48c..cb94fd63 100644 --- a/Api.Data.PostgreSQL/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.PostgreSQL/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 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 @@ -163,6 +163,13 @@ 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`/ diff --git a/Api.Data.PostgreSQL/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Data.PostgreSQL/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Api.Data.PostgreSQL/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Api.Data.PostgreSQL/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's 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 71b15597..f31fd49d 100644 --- a/Api.Data.PostgreSQL/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.PostgreSQL/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Api.Data.PostgreSQL/AGENTS.md b/Api.Data.PostgreSQL/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Api.Data.PostgreSQL/AGENTS.md +++ b/Api.Data.PostgreSQL/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Api.Data.Repository.AutoSave/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Data.Repository.AutoSave/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Api.Data.Repository.AutoSave/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Api.Data.Repository.AutoSave/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's 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 46b8a48c..cb94fd63 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 @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 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 @@ -163,6 +163,13 @@ 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`/ diff --git a/Api.Data.Repository.AutoSave/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Data.Repository.AutoSave/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Api.Data.Repository.AutoSave/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Api.Data.Repository.AutoSave/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's 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 71b15597..f31fd49d 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 @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Api.Data.Repository.AutoSave/AGENTS.md b/Api.Data.Repository.AutoSave/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Api.Data.Repository.AutoSave/AGENTS.md +++ b/Api.Data.Repository.AutoSave/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Api.Data.Repository.Includes/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Data.Repository.Includes/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Api.Data.Repository.Includes/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Api.Data.Repository.Includes/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's 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 46b8a48c..cb94fd63 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 @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 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 @@ -163,6 +163,13 @@ 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`/ diff --git a/Api.Data.Repository.Includes/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Data.Repository.Includes/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Api.Data.Repository.Includes/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Api.Data.Repository.Includes/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's 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 71b15597..f31fd49d 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 @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Api.Data.Repository.Includes/AGENTS.md b/Api.Data.Repository.Includes/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Api.Data.Repository.Includes/AGENTS.md +++ b/Api.Data.Repository.Includes/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Api.Data.SoftDelete/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Data.SoftDelete/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Api.Data.SoftDelete/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Api.Data.SoftDelete/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's 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 46b8a48c..cb94fd63 100644 --- a/Api.Data.SoftDelete/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.SoftDelete/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 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 @@ -163,6 +163,13 @@ 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`/ diff --git a/Api.Data.SoftDelete/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Data.SoftDelete/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Api.Data.SoftDelete/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Api.Data.SoftDelete/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's 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 71b15597..f31fd49d 100644 --- a/Api.Data.SoftDelete/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.SoftDelete/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Api.Data.SoftDelete/AGENTS.md b/Api.Data.SoftDelete/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Api.Data.SoftDelete/AGENTS.md +++ b/Api.Data.SoftDelete/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Api.Data.SqLite/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Data.SqLite/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Api.Data.SqLite/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Api.Data.SqLite/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's 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 46b8a48c..cb94fd63 100644 --- a/Api.Data.SqLite/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.SqLite/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 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 @@ -163,6 +163,13 @@ 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`/ diff --git a/Api.Data.SqLite/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Data.SqLite/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Api.Data.SqLite/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Api.Data.SqLite/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's 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 71b15597..f31fd49d 100644 --- a/Api.Data.SqLite/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.SqLite/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Api.Data.SqLite/AGENTS.md b/Api.Data.SqLite/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Api.Data.SqLite/AGENTS.md +++ b/Api.Data.SqLite/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's 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 46b8a48c..cb94fd63 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 @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 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 @@ -163,6 +163,13 @@ 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`/ diff --git a/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's 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 71b15597..f31fd49d 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 @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Api.Data.SqlServer.Spatial/AGENTS.md b/Api.Data.SqlServer.Spatial/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Api.Data.SqlServer.Spatial/AGENTS.md +++ b/Api.Data.SqlServer.Spatial/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Api.Data.SqlServer/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Data.SqlServer/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Api.Data.SqlServer/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Api.Data.SqlServer/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's 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 46b8a48c..cb94fd63 100644 --- a/Api.Data.SqlServer/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.SqlServer/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 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 @@ -163,6 +163,13 @@ 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`/ diff --git a/Api.Data.SqlServer/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Data.SqlServer/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Api.Data.SqlServer/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Api.Data.SqlServer/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's 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 71b15597..f31fd49d 100644 --- a/Api.Data.SqlServer/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.SqlServer/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Api.Data.SqlServer/AGENTS.md b/Api.Data.SqlServer/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Api.Data.SqlServer/AGENTS.md +++ b/Api.Data.SqlServer/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Api.Data.Triggers/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Data.Triggers/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Api.Data.Triggers/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Api.Data.Triggers/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's 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 46b8a48c..cb94fd63 100644 --- a/Api.Data.Triggers/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.Triggers/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 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 @@ -163,6 +163,13 @@ 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`/ diff --git a/Api.Data.Triggers/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Data.Triggers/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Api.Data.Triggers/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Api.Data.Triggers/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's 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 71b15597..f31fd49d 100644 --- a/Api.Data.Triggers/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.Triggers/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Api.Data.Triggers/AGENTS.md b/Api.Data.Triggers/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Api.Data.Triggers/AGENTS.md +++ b/Api.Data.Triggers/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Api.Documentation.Csp/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Documentation.Csp/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Api.Documentation.Csp/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Api.Documentation.Csp/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's 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 46b8a48c..cb94fd63 100644 --- a/Api.Documentation.Csp/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Documentation.Csp/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 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 @@ -163,6 +163,13 @@ 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`/ diff --git a/Api.Documentation.Csp/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Documentation.Csp/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Api.Documentation.Csp/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Api.Documentation.Csp/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's 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 71b15597..f31fd49d 100644 --- a/Api.Documentation.Csp/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Documentation.Csp/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Api.Documentation.Csp/AGENTS.md b/Api.Documentation.Csp/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Api.Documentation.Csp/AGENTS.md +++ b/Api.Documentation.Csp/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Api.Documentation/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Documentation/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Api.Documentation/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Api.Documentation/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's diff --git a/Api.Documentation/.claude/skills/nano-add-entity/SKILL.md b/Api.Documentation/.claude/skills/nano-add-entity/SKILL.md index 46b8a48c..cb94fd63 100644 --- a/Api.Documentation/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Documentation/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 100644 --- a/Api.Documentation/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Documentation/.claude/skills/nano-add-postman-collection/SKILL.md @@ -163,6 +163,13 @@ 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`/ diff --git a/Api.Documentation/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Documentation/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Api.Documentation/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Api.Documentation/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's diff --git a/Api.Documentation/.github/prompts/nano-add-entity.prompt.md b/Api.Documentation/.github/prompts/nano-add-entity.prompt.md index 71b15597..f31fd49d 100644 --- a/Api.Documentation/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Documentation/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Api.Documentation/AGENTS.md b/Api.Documentation/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Api.Documentation/AGENTS.md +++ b/Api.Documentation/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Api.ErrorHandling/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.ErrorHandling/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Api.ErrorHandling/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Api.ErrorHandling/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's diff --git a/Api.ErrorHandling/.claude/skills/nano-add-entity/SKILL.md b/Api.ErrorHandling/.claude/skills/nano-add-entity/SKILL.md index 46b8a48c..cb94fd63 100644 --- a/Api.ErrorHandling/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.ErrorHandling/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 100644 --- a/Api.ErrorHandling/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.ErrorHandling/.claude/skills/nano-add-postman-collection/SKILL.md @@ -163,6 +163,13 @@ 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`/ diff --git a/Api.ErrorHandling/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.ErrorHandling/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Api.ErrorHandling/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Api.ErrorHandling/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's diff --git a/Api.ErrorHandling/.github/prompts/nano-add-entity.prompt.md b/Api.ErrorHandling/.github/prompts/nano-add-entity.prompt.md index 71b15597..f31fd49d 100644 --- a/Api.ErrorHandling/.github/prompts/nano-add-entity.prompt.md +++ b/Api.ErrorHandling/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Api.ErrorHandling/AGENTS.md b/Api.ErrorHandling/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Api.ErrorHandling/AGENTS.md +++ b/Api.ErrorHandling/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Api.Eventing.RabbitMq/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Eventing.RabbitMq/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Api.Eventing.RabbitMq/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Api.Eventing.RabbitMq/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's 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 46b8a48c..cb94fd63 100644 --- a/Api.Eventing.RabbitMq/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Eventing.RabbitMq/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 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 @@ -163,6 +163,13 @@ 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`/ diff --git a/Api.Eventing.RabbitMq/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Eventing.RabbitMq/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Api.Eventing.RabbitMq/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Api.Eventing.RabbitMq/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's 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 71b15597..f31fd49d 100644 --- a/Api.Eventing.RabbitMq/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Eventing.RabbitMq/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Api.Eventing.RabbitMq/AGENTS.md b/Api.Eventing.RabbitMq/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Api.Eventing.RabbitMq/AGENTS.md +++ b/Api.Eventing.RabbitMq/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Api.HealthChecks/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.HealthChecks/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Api.HealthChecks/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Api.HealthChecks/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's diff --git a/Api.HealthChecks/.claude/skills/nano-add-entity/SKILL.md b/Api.HealthChecks/.claude/skills/nano-add-entity/SKILL.md index 46b8a48c..cb94fd63 100644 --- a/Api.HealthChecks/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.HealthChecks/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 100644 --- a/Api.HealthChecks/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.HealthChecks/.claude/skills/nano-add-postman-collection/SKILL.md @@ -163,6 +163,13 @@ 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`/ diff --git a/Api.HealthChecks/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.HealthChecks/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Api.HealthChecks/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Api.HealthChecks/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's diff --git a/Api.HealthChecks/.github/prompts/nano-add-entity.prompt.md b/Api.HealthChecks/.github/prompts/nano-add-entity.prompt.md index 71b15597..f31fd49d 100644 --- a/Api.HealthChecks/.github/prompts/nano-add-entity.prompt.md +++ b/Api.HealthChecks/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Api.HealthChecks/AGENTS.md b/Api.HealthChecks/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Api.HealthChecks/AGENTS.md +++ b/Api.HealthChecks/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Api.Hosting.Http/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Hosting.Http/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Api.Hosting.Http/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Api.Hosting.Http/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's 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 46b8a48c..cb94fd63 100644 --- a/Api.Hosting.Http/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Hosting.Http/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 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 @@ -163,6 +163,13 @@ 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`/ diff --git a/Api.Hosting.Http/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Hosting.Http/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Api.Hosting.Http/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Api.Hosting.Http/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's 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 71b15597..f31fd49d 100644 --- a/Api.Hosting.Http/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Hosting.Http/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Api.Hosting.Http/AGENTS.md b/Api.Hosting.Http/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Api.Hosting.Http/AGENTS.md +++ b/Api.Hosting.Http/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Api.Hosting.Https/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Hosting.Https/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Api.Hosting.Https/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Api.Hosting.Https/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's 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 46b8a48c..cb94fd63 100644 --- a/Api.Hosting.Https/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Hosting.Https/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 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 @@ -163,6 +163,13 @@ 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`/ diff --git a/Api.Hosting.Https/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Hosting.Https/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Api.Hosting.Https/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Api.Hosting.Https/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's 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 71b15597..f31fd49d 100644 --- a/Api.Hosting.Https/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Hosting.Https/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Api.Hosting.Https/AGENTS.md b/Api.Hosting.Https/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Api.Hosting.Https/AGENTS.md +++ b/Api.Hosting.Https/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Api.Hosting.MultipartLimits/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Hosting.MultipartLimits/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Api.Hosting.MultipartLimits/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Api.Hosting.MultipartLimits/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's 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 46b8a48c..cb94fd63 100644 --- a/Api.Hosting.MultipartLimits/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Hosting.MultipartLimits/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 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 @@ -163,6 +163,13 @@ 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`/ diff --git a/Api.Hosting.MultipartLimits/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Hosting.MultipartLimits/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Api.Hosting.MultipartLimits/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Api.Hosting.MultipartLimits/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's 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 71b15597..f31fd49d 100644 --- a/Api.Hosting.MultipartLimits/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Hosting.MultipartLimits/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Api.Hosting.MultipartLimits/AGENTS.md b/Api.Hosting.MultipartLimits/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Api.Hosting.MultipartLimits/AGENTS.md +++ b/Api.Hosting.MultipartLimits/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Api.Localization/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Localization/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Api.Localization/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Api.Localization/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's diff --git a/Api.Localization/.claude/skills/nano-add-entity/SKILL.md b/Api.Localization/.claude/skills/nano-add-entity/SKILL.md index 46b8a48c..cb94fd63 100644 --- a/Api.Localization/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Localization/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 100644 --- a/Api.Localization/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Localization/.claude/skills/nano-add-postman-collection/SKILL.md @@ -163,6 +163,13 @@ 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`/ diff --git a/Api.Localization/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Localization/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Api.Localization/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Api.Localization/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's diff --git a/Api.Localization/.github/prompts/nano-add-entity.prompt.md b/Api.Localization/.github/prompts/nano-add-entity.prompt.md index 71b15597..f31fd49d 100644 --- a/Api.Localization/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Localization/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Api.Localization/AGENTS.md b/Api.Localization/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Api.Localization/AGENTS.md +++ b/Api.Localization/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Api.Logging.Log4Net/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Logging.Log4Net/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Api.Logging.Log4Net/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Api.Logging.Log4Net/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's 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 46b8a48c..cb94fd63 100644 --- a/Api.Logging.Log4Net/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Logging.Log4Net/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 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 @@ -163,6 +163,13 @@ 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`/ diff --git a/Api.Logging.Log4Net/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Logging.Log4Net/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Api.Logging.Log4Net/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Api.Logging.Log4Net/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's 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 71b15597..f31fd49d 100644 --- a/Api.Logging.Log4Net/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Logging.Log4Net/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Api.Logging.Log4Net/AGENTS.md b/Api.Logging.Log4Net/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Api.Logging.Log4Net/AGENTS.md +++ b/Api.Logging.Log4Net/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Api.Logging.Microsoft/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Logging.Microsoft/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Api.Logging.Microsoft/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Api.Logging.Microsoft/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's 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 46b8a48c..cb94fd63 100644 --- a/Api.Logging.Microsoft/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Logging.Microsoft/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 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 @@ -163,6 +163,13 @@ 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`/ diff --git a/Api.Logging.Microsoft/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Logging.Microsoft/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Api.Logging.Microsoft/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Api.Logging.Microsoft/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's 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 71b15597..f31fd49d 100644 --- a/Api.Logging.Microsoft/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Logging.Microsoft/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Api.Logging.Microsoft/AGENTS.md b/Api.Logging.Microsoft/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Api.Logging.Microsoft/AGENTS.md +++ b/Api.Logging.Microsoft/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Api.Logging.NLog/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Logging.NLog/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Api.Logging.NLog/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Api.Logging.NLog/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's 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 46b8a48c..cb94fd63 100644 --- a/Api.Logging.NLog/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Logging.NLog/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 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 @@ -163,6 +163,13 @@ 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`/ diff --git a/Api.Logging.NLog/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Logging.NLog/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Api.Logging.NLog/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Api.Logging.NLog/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's 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 71b15597..f31fd49d 100644 --- a/Api.Logging.NLog/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Logging.NLog/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Api.Logging.NLog/AGENTS.md b/Api.Logging.NLog/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Api.Logging.NLog/AGENTS.md +++ b/Api.Logging.NLog/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Api.Logging.Serilog/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Logging.Serilog/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Api.Logging.Serilog/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Api.Logging.Serilog/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's 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 46b8a48c..cb94fd63 100644 --- a/Api.Logging.Serilog/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Logging.Serilog/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 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 @@ -163,6 +163,13 @@ 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`/ diff --git a/Api.Logging.Serilog/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Logging.Serilog/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Api.Logging.Serilog/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Api.Logging.Serilog/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's 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 71b15597..f31fd49d 100644 --- a/Api.Logging.Serilog/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Logging.Serilog/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Api.Logging.Serilog/AGENTS.md b/Api.Logging.Serilog/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Api.Logging.Serilog/AGENTS.md +++ b/Api.Logging.Serilog/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Api.Metrics/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Metrics/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Api.Metrics/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Api.Metrics/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's diff --git a/Api.Metrics/.claude/skills/nano-add-entity/SKILL.md b/Api.Metrics/.claude/skills/nano-add-entity/SKILL.md index 46b8a48c..cb94fd63 100644 --- a/Api.Metrics/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Metrics/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 100644 --- a/Api.Metrics/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Metrics/.claude/skills/nano-add-postman-collection/SKILL.md @@ -163,6 +163,13 @@ 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`/ diff --git a/Api.Metrics/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Metrics/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Api.Metrics/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Api.Metrics/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's diff --git a/Api.Metrics/.github/prompts/nano-add-entity.prompt.md b/Api.Metrics/.github/prompts/nano-add-entity.prompt.md index 71b15597..f31fd49d 100644 --- a/Api.Metrics/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Metrics/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Api.Metrics/AGENTS.md b/Api.Metrics/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Api.Metrics/AGENTS.md +++ b/Api.Metrics/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Api.MultipartJson/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.MultipartJson/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Api.MultipartJson/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Api.MultipartJson/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's diff --git a/Api.MultipartJson/.claude/skills/nano-add-entity/SKILL.md b/Api.MultipartJson/.claude/skills/nano-add-entity/SKILL.md index 46b8a48c..cb94fd63 100644 --- a/Api.MultipartJson/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.MultipartJson/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 100644 --- a/Api.MultipartJson/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.MultipartJson/.claude/skills/nano-add-postman-collection/SKILL.md @@ -163,6 +163,13 @@ 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`/ diff --git a/Api.MultipartJson/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.MultipartJson/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Api.MultipartJson/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Api.MultipartJson/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's diff --git a/Api.MultipartJson/.github/prompts/nano-add-entity.prompt.md b/Api.MultipartJson/.github/prompts/nano-add-entity.prompt.md index 71b15597..f31fd49d 100644 --- a/Api.MultipartJson/.github/prompts/nano-add-entity.prompt.md +++ b/Api.MultipartJson/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Api.MultipartJson/AGENTS.md b/Api.MultipartJson/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Api.MultipartJson/AGENTS.md +++ b/Api.MultipartJson/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's 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 46b8a48c..cb94fd63 100644 --- a/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 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 @@ -163,6 +163,13 @@ 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`/ diff --git a/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's 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 71b15597..f31fd49d 100644 --- a/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-entity.prompt.md +++ b/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Api.PolicyHeaders.ContentSecurityPolicy/AGENTS.md b/Api.PolicyHeaders.ContentSecurityPolicy/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Api.PolicyHeaders.ContentSecurityPolicy/AGENTS.md +++ b/Api.PolicyHeaders.ContentSecurityPolicy/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's 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 46b8a48c..cb94fd63 100644 --- a/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 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 @@ -163,6 +163,13 @@ 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`/ diff --git a/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's 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 71b15597..f31fd49d 100644 --- a/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-entity.prompt.md +++ b/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Api.PolicyHeaders.ContentTypeOptions/AGENTS.md b/Api.PolicyHeaders.ContentTypeOptions/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Api.PolicyHeaders.ContentTypeOptions/AGENTS.md +++ b/Api.PolicyHeaders.ContentTypeOptions/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Api.PolicyHeaders.Cors/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.PolicyHeaders.Cors/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Api.PolicyHeaders.Cors/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Api.PolicyHeaders.Cors/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's 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 46b8a48c..cb94fd63 100644 --- a/Api.PolicyHeaders.Cors/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.PolicyHeaders.Cors/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 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 @@ -163,6 +163,13 @@ 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`/ diff --git a/Api.PolicyHeaders.Cors/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.PolicyHeaders.Cors/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Api.PolicyHeaders.Cors/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Api.PolicyHeaders.Cors/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's 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 71b15597..f31fd49d 100644 --- a/Api.PolicyHeaders.Cors/.github/prompts/nano-add-entity.prompt.md +++ b/Api.PolicyHeaders.Cors/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Api.PolicyHeaders.Cors/AGENTS.md b/Api.PolicyHeaders.Cors/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Api.PolicyHeaders.Cors/AGENTS.md +++ b/Api.PolicyHeaders.Cors/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's 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 46b8a48c..cb94fd63 100644 --- a/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 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 @@ -163,6 +163,13 @@ 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`/ diff --git a/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's 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 71b15597..f31fd49d 100644 --- a/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-entity.prompt.md +++ b/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Api.PolicyHeaders.ForwardedHeaders/AGENTS.md b/Api.PolicyHeaders.ForwardedHeaders/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Api.PolicyHeaders.ForwardedHeaders/AGENTS.md +++ b/Api.PolicyHeaders.ForwardedHeaders/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's 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 46b8a48c..cb94fd63 100644 --- a/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 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 @@ -163,6 +163,13 @@ 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`/ diff --git a/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's 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 71b15597..f31fd49d 100644 --- a/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-entity.prompt.md +++ b/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Api.PolicyHeaders.FrameOptions/AGENTS.md b/Api.PolicyHeaders.FrameOptions/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Api.PolicyHeaders.FrameOptions/AGENTS.md +++ b/Api.PolicyHeaders.FrameOptions/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's 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 46b8a48c..cb94fd63 100644 --- a/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 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 @@ -163,6 +163,13 @@ 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`/ diff --git a/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's 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 71b15597..f31fd49d 100644 --- a/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-entity.prompt.md +++ b/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Api.PolicyHeaders.Hsts/AGENTS.md b/Api.PolicyHeaders.Hsts/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Api.PolicyHeaders.Hsts/AGENTS.md +++ b/Api.PolicyHeaders.Hsts/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's 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 46b8a48c..cb94fd63 100644 --- a/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 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 @@ -163,6 +163,13 @@ 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`/ diff --git a/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's 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 71b15597..f31fd49d 100644 --- a/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-entity.prompt.md +++ b/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Api.PolicyHeaders.ReferrerPolicy/AGENTS.md b/Api.PolicyHeaders.ReferrerPolicy/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Api.PolicyHeaders.ReferrerPolicy/AGENTS.md +++ b/Api.PolicyHeaders.ReferrerPolicy/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Api.PolicyHeaders.Robots/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.PolicyHeaders.Robots/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Api.PolicyHeaders.Robots/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Api.PolicyHeaders.Robots/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's 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 46b8a48c..cb94fd63 100644 --- a/Api.PolicyHeaders.Robots/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.PolicyHeaders.Robots/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 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 @@ -163,6 +163,13 @@ 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`/ diff --git a/Api.PolicyHeaders.Robots/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.PolicyHeaders.Robots/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Api.PolicyHeaders.Robots/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Api.PolicyHeaders.Robots/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's 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 71b15597..f31fd49d 100644 --- a/Api.PolicyHeaders.Robots/.github/prompts/nano-add-entity.prompt.md +++ b/Api.PolicyHeaders.Robots/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Api.PolicyHeaders.Robots/AGENTS.md b/Api.PolicyHeaders.Robots/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Api.PolicyHeaders.Robots/AGENTS.md +++ b/Api.PolicyHeaders.Robots/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's 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 46b8a48c..cb94fd63 100644 --- a/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 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 @@ -163,6 +163,13 @@ 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`/ diff --git a/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's 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 71b15597..f31fd49d 100644 --- a/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-entity.prompt.md +++ b/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Api.PolicyHeaders.XssProtection/AGENTS.md b/Api.PolicyHeaders.XssProtection/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Api.PolicyHeaders.XssProtection/AGENTS.md +++ b/Api.PolicyHeaders.XssProtection/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Api.RequestTracing/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.RequestTracing/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Api.RequestTracing/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Api.RequestTracing/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's diff --git a/Api.RequestTracing/.claude/skills/nano-add-entity/SKILL.md b/Api.RequestTracing/.claude/skills/nano-add-entity/SKILL.md index 46b8a48c..cb94fd63 100644 --- a/Api.RequestTracing/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.RequestTracing/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 100644 --- a/Api.RequestTracing/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.RequestTracing/.claude/skills/nano-add-postman-collection/SKILL.md @@ -163,6 +163,13 @@ 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`/ diff --git a/Api.RequestTracing/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.RequestTracing/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Api.RequestTracing/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Api.RequestTracing/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's diff --git a/Api.RequestTracing/.github/prompts/nano-add-entity.prompt.md b/Api.RequestTracing/.github/prompts/nano-add-entity.prompt.md index 71b15597..f31fd49d 100644 --- a/Api.RequestTracing/.github/prompts/nano-add-entity.prompt.md +++ b/Api.RequestTracing/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Api.RequestTracing/AGENTS.md b/Api.RequestTracing/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Api.RequestTracing/AGENTS.md +++ b/Api.RequestTracing/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Api.ResponseCache/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.ResponseCache/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Api.ResponseCache/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Api.ResponseCache/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's diff --git a/Api.ResponseCache/.claude/skills/nano-add-entity/SKILL.md b/Api.ResponseCache/.claude/skills/nano-add-entity/SKILL.md index 46b8a48c..cb94fd63 100644 --- a/Api.ResponseCache/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.ResponseCache/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 100644 --- a/Api.ResponseCache/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.ResponseCache/.claude/skills/nano-add-postman-collection/SKILL.md @@ -163,6 +163,13 @@ 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`/ diff --git a/Api.ResponseCache/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.ResponseCache/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Api.ResponseCache/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Api.ResponseCache/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's diff --git a/Api.ResponseCache/.github/prompts/nano-add-entity.prompt.md b/Api.ResponseCache/.github/prompts/nano-add-entity.prompt.md index 71b15597..f31fd49d 100644 --- a/Api.ResponseCache/.github/prompts/nano-add-entity.prompt.md +++ b/Api.ResponseCache/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Api.ResponseCache/AGENTS.md b/Api.ResponseCache/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Api.ResponseCache/AGENTS.md +++ b/Api.ResponseCache/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Api.ResponseCompression/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.ResponseCompression/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Api.ResponseCompression/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Api.ResponseCompression/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's diff --git a/Api.ResponseCompression/.claude/skills/nano-add-entity/SKILL.md b/Api.ResponseCompression/.claude/skills/nano-add-entity/SKILL.md index 46b8a48c..cb94fd63 100644 --- a/Api.ResponseCompression/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.ResponseCompression/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 100644 --- a/Api.ResponseCompression/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.ResponseCompression/.claude/skills/nano-add-postman-collection/SKILL.md @@ -163,6 +163,13 @@ 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`/ diff --git a/Api.ResponseCompression/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.ResponseCompression/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Api.ResponseCompression/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Api.ResponseCompression/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's diff --git a/Api.ResponseCompression/.github/prompts/nano-add-entity.prompt.md b/Api.ResponseCompression/.github/prompts/nano-add-entity.prompt.md index 71b15597..f31fd49d 100644 --- a/Api.ResponseCompression/.github/prompts/nano-add-entity.prompt.md +++ b/Api.ResponseCompression/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Api.ResponseCompression/AGENTS.md b/Api.ResponseCompression/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Api.ResponseCompression/AGENTS.md +++ b/Api.ResponseCompression/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Api.Session/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Session/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Api.Session/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Api.Session/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's diff --git a/Api.Session/.claude/skills/nano-add-entity/SKILL.md b/Api.Session/.claude/skills/nano-add-entity/SKILL.md index 46b8a48c..cb94fd63 100644 --- a/Api.Session/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Session/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 100644 --- a/Api.Session/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Session/.claude/skills/nano-add-postman-collection/SKILL.md @@ -163,6 +163,13 @@ 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`/ diff --git a/Api.Session/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Session/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Api.Session/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Api.Session/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's diff --git a/Api.Session/.github/prompts/nano-add-entity.prompt.md b/Api.Session/.github/prompts/nano-add-entity.prompt.md index 71b15597..f31fd49d 100644 --- a/Api.Session/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Session/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Api.Session/AGENTS.md b/Api.Session/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Api.Session/AGENTS.md +++ b/Api.Session/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Api.StartupTasks/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.StartupTasks/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Api.StartupTasks/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Api.StartupTasks/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's diff --git a/Api.StartupTasks/.claude/skills/nano-add-entity/SKILL.md b/Api.StartupTasks/.claude/skills/nano-add-entity/SKILL.md index 46b8a48c..cb94fd63 100644 --- a/Api.StartupTasks/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.StartupTasks/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 100644 --- a/Api.StartupTasks/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.StartupTasks/.claude/skills/nano-add-postman-collection/SKILL.md @@ -163,6 +163,13 @@ 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`/ diff --git a/Api.StartupTasks/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.StartupTasks/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Api.StartupTasks/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Api.StartupTasks/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's diff --git a/Api.StartupTasks/.github/prompts/nano-add-entity.prompt.md b/Api.StartupTasks/.github/prompts/nano-add-entity.prompt.md index 71b15597..f31fd49d 100644 --- a/Api.StartupTasks/.github/prompts/nano-add-entity.prompt.md +++ b/Api.StartupTasks/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Api.StartupTasks/AGENTS.md b/Api.StartupTasks/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Api.StartupTasks/AGENTS.md +++ b/Api.StartupTasks/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Api.StaticFiles/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.StaticFiles/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Api.StaticFiles/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Api.StaticFiles/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's diff --git a/Api.StaticFiles/.claude/skills/nano-add-entity/SKILL.md b/Api.StaticFiles/.claude/skills/nano-add-entity/SKILL.md index 46b8a48c..cb94fd63 100644 --- a/Api.StaticFiles/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.StaticFiles/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 100644 --- a/Api.StaticFiles/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.StaticFiles/.claude/skills/nano-add-postman-collection/SKILL.md @@ -163,6 +163,13 @@ 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`/ diff --git a/Api.StaticFiles/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.StaticFiles/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Api.StaticFiles/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Api.StaticFiles/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's diff --git a/Api.StaticFiles/.github/prompts/nano-add-entity.prompt.md b/Api.StaticFiles/.github/prompts/nano-add-entity.prompt.md index 71b15597..f31fd49d 100644 --- a/Api.StaticFiles/.github/prompts/nano-add-entity.prompt.md +++ b/Api.StaticFiles/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Api.StaticFiles/AGENTS.md b/Api.StaticFiles/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Api.StaticFiles/AGENTS.md +++ b/Api.StaticFiles/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Api.Storage.Azure/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Storage.Azure/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Api.Storage.Azure/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Api.Storage.Azure/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's 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 46b8a48c..cb94fd63 100644 --- a/Api.Storage.Azure/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Storage.Azure/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 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 @@ -163,6 +163,13 @@ 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`/ diff --git a/Api.Storage.Azure/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Storage.Azure/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Api.Storage.Azure/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Api.Storage.Azure/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's 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 71b15597..f31fd49d 100644 --- a/Api.Storage.Azure/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Storage.Azure/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Api.Storage.Azure/AGENTS.md b/Api.Storage.Azure/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Api.Storage.Azure/AGENTS.md +++ b/Api.Storage.Azure/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Api.Storage.Local/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Storage.Local/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Api.Storage.Local/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Api.Storage.Local/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's 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 46b8a48c..cb94fd63 100644 --- a/Api.Storage.Local/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Storage.Local/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 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 @@ -163,6 +163,13 @@ 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`/ diff --git a/Api.Storage.Local/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Storage.Local/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Api.Storage.Local/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Api.Storage.Local/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's 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 71b15597..f31fd49d 100644 --- a/Api.Storage.Local/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Storage.Local/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Api.Storage.Local/AGENTS.md b/Api.Storage.Local/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Api.Storage.Local/AGENTS.md +++ b/Api.Storage.Local/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Api.TimeZone/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.TimeZone/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Api.TimeZone/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Api.TimeZone/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's diff --git a/Api.TimeZone/.claude/skills/nano-add-entity/SKILL.md b/Api.TimeZone/.claude/skills/nano-add-entity/SKILL.md index 46b8a48c..cb94fd63 100644 --- a/Api.TimeZone/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.TimeZone/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 100644 --- a/Api.TimeZone/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.TimeZone/.claude/skills/nano-add-postman-collection/SKILL.md @@ -163,6 +163,13 @@ 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`/ diff --git a/Api.TimeZone/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.TimeZone/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Api.TimeZone/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Api.TimeZone/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's diff --git a/Api.TimeZone/.github/prompts/nano-add-entity.prompt.md b/Api.TimeZone/.github/prompts/nano-add-entity.prompt.md index 71b15597..f31fd49d 100644 --- a/Api.TimeZone/.github/prompts/nano-add-entity.prompt.md +++ b/Api.TimeZone/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Api.TimeZone/AGENTS.md b/Api.TimeZone/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Api.TimeZone/AGENTS.md +++ b/Api.TimeZone/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Api.Versioning/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.Versioning/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Api.Versioning/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Api.Versioning/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's diff --git a/Api.Versioning/.claude/skills/nano-add-entity/SKILL.md b/Api.Versioning/.claude/skills/nano-add-entity/SKILL.md index 46b8a48c..cb94fd63 100644 --- a/Api.Versioning/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Versioning/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 100644 --- a/Api.Versioning/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Versioning/.claude/skills/nano-add-postman-collection/SKILL.md @@ -163,6 +163,13 @@ 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`/ diff --git a/Api.Versioning/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.Versioning/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Api.Versioning/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Api.Versioning/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's diff --git a/Api.Versioning/.github/prompts/nano-add-entity.prompt.md b/Api.Versioning/.github/prompts/nano-add-entity.prompt.md index 71b15597..f31fd49d 100644 --- a/Api.Versioning/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Versioning/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Api.Versioning/AGENTS.md b/Api.Versioning/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Api.Versioning/AGENTS.md +++ b/Api.Versioning/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Api.VirusScan/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api.VirusScan/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Api.VirusScan/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Api.VirusScan/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's diff --git a/Api.VirusScan/.claude/skills/nano-add-entity/SKILL.md b/Api.VirusScan/.claude/skills/nano-add-entity/SKILL.md index 46b8a48c..cb94fd63 100644 --- a/Api.VirusScan/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.VirusScan/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 100644 --- a/Api.VirusScan/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.VirusScan/.claude/skills/nano-add-postman-collection/SKILL.md @@ -163,6 +163,13 @@ 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`/ diff --git a/Api.VirusScan/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api.VirusScan/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Api.VirusScan/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Api.VirusScan/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's diff --git a/Api.VirusScan/.github/prompts/nano-add-entity.prompt.md b/Api.VirusScan/.github/prompts/nano-add-entity.prompt.md index 71b15597..f31fd49d 100644 --- a/Api.VirusScan/.github/prompts/nano-add-entity.prompt.md +++ b/Api.VirusScan/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Api.VirusScan/AGENTS.md b/Api.VirusScan/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Api.VirusScan/AGENTS.md +++ b/Api.VirusScan/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Api._Blank/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Api._Blank/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Api._Blank/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Api._Blank/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's diff --git a/Api._Blank/.claude/skills/nano-add-entity/SKILL.md b/Api._Blank/.claude/skills/nano-add-entity/SKILL.md index 46b8a48c..cb94fd63 100644 --- a/Api._Blank/.claude/skills/nano-add-entity/SKILL.md +++ b/Api._Blank/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 100644 --- a/Api._Blank/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api._Blank/.claude/skills/nano-add-postman-collection/SKILL.md @@ -163,6 +163,13 @@ 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`/ diff --git a/Api._Blank/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Api._Blank/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Api._Blank/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Api._Blank/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's diff --git a/Api._Blank/.github/prompts/nano-add-entity.prompt.md b/Api._Blank/.github/prompts/nano-add-entity.prompt.md index 71b15597..f31fd49d 100644 --- a/Api._Blank/.github/prompts/nano-add-entity.prompt.md +++ b/Api._Blank/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Api._Blank/AGENTS.md b/Api._Blank/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Api._Blank/AGENTS.md +++ b/Api._Blank/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Console.CustomConfigSection/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Console.CustomConfigSection/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Console.CustomConfigSection/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Console.CustomConfigSection/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's diff --git a/Console.CustomConfigSection/.claude/skills/nano-add-entity/SKILL.md b/Console.CustomConfigSection/.claude/skills/nano-add-entity/SKILL.md index 46b8a48c..cb94fd63 100644 --- a/Console.CustomConfigSection/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.CustomConfigSection/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 100644 --- a/Console.CustomConfigSection/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Console.CustomConfigSection/.claude/skills/nano-add-postman-collection/SKILL.md @@ -163,6 +163,13 @@ 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`/ diff --git a/Console.CustomConfigSection/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Console.CustomConfigSection/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Console.CustomConfigSection/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Console.CustomConfigSection/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's diff --git a/Console.CustomConfigSection/.github/prompts/nano-add-entity.prompt.md b/Console.CustomConfigSection/.github/prompts/nano-add-entity.prompt.md index 71b15597..f31fd49d 100644 --- a/Console.CustomConfigSection/.github/prompts/nano-add-entity.prompt.md +++ b/Console.CustomConfigSection/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Console.CustomConfigSection/AGENTS.md b/Console.CustomConfigSection/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Console.CustomConfigSection/AGENTS.md +++ b/Console.CustomConfigSection/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Console.CustomService/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Console.CustomService/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Console.CustomService/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Console.CustomService/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's diff --git a/Console.CustomService/.claude/skills/nano-add-entity/SKILL.md b/Console.CustomService/.claude/skills/nano-add-entity/SKILL.md index 46b8a48c..cb94fd63 100644 --- a/Console.CustomService/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.CustomService/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 100644 --- a/Console.CustomService/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Console.CustomService/.claude/skills/nano-add-postman-collection/SKILL.md @@ -163,6 +163,13 @@ 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`/ diff --git a/Console.CustomService/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Console.CustomService/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Console.CustomService/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Console.CustomService/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's diff --git a/Console.CustomService/.github/prompts/nano-add-entity.prompt.md b/Console.CustomService/.github/prompts/nano-add-entity.prompt.md index 71b15597..f31fd49d 100644 --- a/Console.CustomService/.github/prompts/nano-add-entity.prompt.md +++ b/Console.CustomService/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Console.CustomService/AGENTS.md b/Console.CustomService/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Console.CustomService/AGENTS.md +++ b/Console.CustomService/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Console.Data.InMemory/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Console.Data.InMemory/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Console.Data.InMemory/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Console.Data.InMemory/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's 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 46b8a48c..cb94fd63 100644 --- a/Console.Data.InMemory/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Data.InMemory/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 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 @@ -163,6 +163,13 @@ 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`/ diff --git a/Console.Data.InMemory/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Console.Data.InMemory/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Console.Data.InMemory/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Console.Data.InMemory/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's 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 71b15597..f31fd49d 100644 --- a/Console.Data.InMemory/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Data.InMemory/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Console.Data.InMemory/AGENTS.md b/Console.Data.InMemory/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Console.Data.InMemory/AGENTS.md +++ b/Console.Data.InMemory/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Console.Data.MySql/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Console.Data.MySql/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Console.Data.MySql/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Console.Data.MySql/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's 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 46b8a48c..cb94fd63 100644 --- a/Console.Data.MySql/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Data.MySql/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 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 @@ -163,6 +163,13 @@ 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`/ diff --git a/Console.Data.MySql/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Console.Data.MySql/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Console.Data.MySql/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Console.Data.MySql/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's 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 71b15597..f31fd49d 100644 --- a/Console.Data.MySql/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Data.MySql/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Console.Data.MySql/AGENTS.md b/Console.Data.MySql/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Console.Data.MySql/AGENTS.md +++ b/Console.Data.MySql/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Console.Data.PostgreSQL/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Console.Data.PostgreSQL/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Console.Data.PostgreSQL/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Console.Data.PostgreSQL/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's 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 46b8a48c..cb94fd63 100644 --- a/Console.Data.PostgreSQL/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Data.PostgreSQL/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 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 @@ -163,6 +163,13 @@ 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`/ diff --git a/Console.Data.PostgreSQL/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Console.Data.PostgreSQL/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Console.Data.PostgreSQL/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Console.Data.PostgreSQL/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's 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 71b15597..f31fd49d 100644 --- a/Console.Data.PostgreSQL/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Data.PostgreSQL/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Console.Data.PostgreSQL/AGENTS.md b/Console.Data.PostgreSQL/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Console.Data.PostgreSQL/AGENTS.md +++ b/Console.Data.PostgreSQL/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Console.Data.SqLite/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Console.Data.SqLite/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Console.Data.SqLite/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Console.Data.SqLite/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's 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 46b8a48c..cb94fd63 100644 --- a/Console.Data.SqLite/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Data.SqLite/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 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 @@ -163,6 +163,13 @@ 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`/ diff --git a/Console.Data.SqLite/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Console.Data.SqLite/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Console.Data.SqLite/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Console.Data.SqLite/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's 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 71b15597..f31fd49d 100644 --- a/Console.Data.SqLite/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Data.SqLite/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Console.Data.SqLite/AGENTS.md b/Console.Data.SqLite/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Console.Data.SqLite/AGENTS.md +++ b/Console.Data.SqLite/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Console.Data.SqlServer/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Console.Data.SqlServer/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Console.Data.SqlServer/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Console.Data.SqlServer/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's 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 46b8a48c..cb94fd63 100644 --- a/Console.Data.SqlServer/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Data.SqlServer/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 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 @@ -163,6 +163,13 @@ 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`/ diff --git a/Console.Data.SqlServer/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Console.Data.SqlServer/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Console.Data.SqlServer/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Console.Data.SqlServer/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's 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 71b15597..f31fd49d 100644 --- a/Console.Data.SqlServer/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Data.SqlServer/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Console.Data.SqlServer/AGENTS.md b/Console.Data.SqlServer/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Console.Data.SqlServer/AGENTS.md +++ b/Console.Data.SqlServer/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Console.Eventing.RabbitMq/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Console.Eventing.RabbitMq/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Console.Eventing.RabbitMq/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Console.Eventing.RabbitMq/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's 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 46b8a48c..cb94fd63 100644 --- a/Console.Eventing.RabbitMq/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Eventing.RabbitMq/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 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 @@ -163,6 +163,13 @@ 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`/ diff --git a/Console.Eventing.RabbitMq/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Console.Eventing.RabbitMq/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Console.Eventing.RabbitMq/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Console.Eventing.RabbitMq/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's 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 71b15597..f31fd49d 100644 --- a/Console.Eventing.RabbitMq/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Eventing.RabbitMq/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Console.Eventing.RabbitMq/AGENTS.md b/Console.Eventing.RabbitMq/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Console.Eventing.RabbitMq/AGENTS.md +++ b/Console.Eventing.RabbitMq/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Console.ExceptionHandling/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Console.ExceptionHandling/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Console.ExceptionHandling/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Console.ExceptionHandling/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's diff --git a/Console.ExceptionHandling/.claude/skills/nano-add-entity/SKILL.md b/Console.ExceptionHandling/.claude/skills/nano-add-entity/SKILL.md index 46b8a48c..cb94fd63 100644 --- a/Console.ExceptionHandling/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.ExceptionHandling/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 100644 --- a/Console.ExceptionHandling/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Console.ExceptionHandling/.claude/skills/nano-add-postman-collection/SKILL.md @@ -163,6 +163,13 @@ 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`/ diff --git a/Console.ExceptionHandling/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Console.ExceptionHandling/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Console.ExceptionHandling/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Console.ExceptionHandling/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's diff --git a/Console.ExceptionHandling/.github/prompts/nano-add-entity.prompt.md b/Console.ExceptionHandling/.github/prompts/nano-add-entity.prompt.md index 71b15597..f31fd49d 100644 --- a/Console.ExceptionHandling/.github/prompts/nano-add-entity.prompt.md +++ b/Console.ExceptionHandling/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Console.ExceptionHandling/AGENTS.md b/Console.ExceptionHandling/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Console.ExceptionHandling/AGENTS.md +++ b/Console.ExceptionHandling/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Console.Localization/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Console.Localization/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Console.Localization/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Console.Localization/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's diff --git a/Console.Localization/.claude/skills/nano-add-entity/SKILL.md b/Console.Localization/.claude/skills/nano-add-entity/SKILL.md index 46b8a48c..cb94fd63 100644 --- a/Console.Localization/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Localization/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 100644 --- a/Console.Localization/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Console.Localization/.claude/skills/nano-add-postman-collection/SKILL.md @@ -163,6 +163,13 @@ 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`/ diff --git a/Console.Localization/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Console.Localization/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Console.Localization/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Console.Localization/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's diff --git a/Console.Localization/.github/prompts/nano-add-entity.prompt.md b/Console.Localization/.github/prompts/nano-add-entity.prompt.md index 71b15597..f31fd49d 100644 --- a/Console.Localization/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Localization/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Console.Localization/AGENTS.md b/Console.Localization/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Console.Localization/AGENTS.md +++ b/Console.Localization/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Console.Logging.Log4Net/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Console.Logging.Log4Net/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Console.Logging.Log4Net/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Console.Logging.Log4Net/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's 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 46b8a48c..cb94fd63 100644 --- a/Console.Logging.Log4Net/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Logging.Log4Net/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 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 @@ -163,6 +163,13 @@ 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`/ diff --git a/Console.Logging.Log4Net/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Console.Logging.Log4Net/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Console.Logging.Log4Net/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Console.Logging.Log4Net/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's 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 71b15597..f31fd49d 100644 --- a/Console.Logging.Log4Net/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Logging.Log4Net/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Console.Logging.Log4Net/AGENTS.md b/Console.Logging.Log4Net/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Console.Logging.Log4Net/AGENTS.md +++ b/Console.Logging.Log4Net/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Console.Logging.Microsoft/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Console.Logging.Microsoft/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Console.Logging.Microsoft/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Console.Logging.Microsoft/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's 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 46b8a48c..cb94fd63 100644 --- a/Console.Logging.Microsoft/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Logging.Microsoft/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 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 @@ -163,6 +163,13 @@ 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`/ diff --git a/Console.Logging.Microsoft/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Console.Logging.Microsoft/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Console.Logging.Microsoft/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Console.Logging.Microsoft/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's 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 71b15597..f31fd49d 100644 --- a/Console.Logging.Microsoft/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Logging.Microsoft/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Console.Logging.Microsoft/AGENTS.md b/Console.Logging.Microsoft/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Console.Logging.Microsoft/AGENTS.md +++ b/Console.Logging.Microsoft/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Console.Logging.NLog/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Console.Logging.NLog/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Console.Logging.NLog/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Console.Logging.NLog/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's 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 46b8a48c..cb94fd63 100644 --- a/Console.Logging.NLog/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Logging.NLog/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 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 @@ -163,6 +163,13 @@ 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`/ diff --git a/Console.Logging.NLog/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Console.Logging.NLog/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Console.Logging.NLog/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Console.Logging.NLog/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's 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 71b15597..f31fd49d 100644 --- a/Console.Logging.NLog/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Logging.NLog/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Console.Logging.NLog/AGENTS.md b/Console.Logging.NLog/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Console.Logging.NLog/AGENTS.md +++ b/Console.Logging.NLog/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Console.Logging.Serilog/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Console.Logging.Serilog/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Console.Logging.Serilog/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Console.Logging.Serilog/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's 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 46b8a48c..cb94fd63 100644 --- a/Console.Logging.Serilog/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Logging.Serilog/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 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 @@ -163,6 +163,13 @@ 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`/ diff --git a/Console.Logging.Serilog/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Console.Logging.Serilog/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Console.Logging.Serilog/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Console.Logging.Serilog/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's 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 71b15597..f31fd49d 100644 --- a/Console.Logging.Serilog/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Logging.Serilog/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Console.Logging.Serilog/AGENTS.md b/Console.Logging.Serilog/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Console.Logging.Serilog/AGENTS.md +++ b/Console.Logging.Serilog/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Console.StartupTasks/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Console.StartupTasks/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Console.StartupTasks/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Console.StartupTasks/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's diff --git a/Console.StartupTasks/.claude/skills/nano-add-entity/SKILL.md b/Console.StartupTasks/.claude/skills/nano-add-entity/SKILL.md index 46b8a48c..cb94fd63 100644 --- a/Console.StartupTasks/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.StartupTasks/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 100644 --- a/Console.StartupTasks/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Console.StartupTasks/.claude/skills/nano-add-postman-collection/SKILL.md @@ -163,6 +163,13 @@ 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`/ diff --git a/Console.StartupTasks/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Console.StartupTasks/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Console.StartupTasks/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Console.StartupTasks/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's diff --git a/Console.StartupTasks/.github/prompts/nano-add-entity.prompt.md b/Console.StartupTasks/.github/prompts/nano-add-entity.prompt.md index 71b15597..f31fd49d 100644 --- a/Console.StartupTasks/.github/prompts/nano-add-entity.prompt.md +++ b/Console.StartupTasks/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Console.StartupTasks/AGENTS.md b/Console.StartupTasks/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Console.StartupTasks/AGENTS.md +++ b/Console.StartupTasks/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Console.Storage.Azure/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Console.Storage.Azure/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Console.Storage.Azure/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Console.Storage.Azure/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's 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 46b8a48c..cb94fd63 100644 --- a/Console.Storage.Azure/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Storage.Azure/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 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 @@ -163,6 +163,13 @@ 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`/ diff --git a/Console.Storage.Azure/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Console.Storage.Azure/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Console.Storage.Azure/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Console.Storage.Azure/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's 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 71b15597..f31fd49d 100644 --- a/Console.Storage.Azure/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Storage.Azure/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Console.Storage.Azure/AGENTS.md b/Console.Storage.Azure/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Console.Storage.Azure/AGENTS.md +++ b/Console.Storage.Azure/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Console.Storage.Local/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Console.Storage.Local/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Console.Storage.Local/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Console.Storage.Local/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's 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 46b8a48c..cb94fd63 100644 --- a/Console.Storage.Local/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Storage.Local/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 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 @@ -163,6 +163,13 @@ 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`/ diff --git a/Console.Storage.Local/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Console.Storage.Local/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Console.Storage.Local/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Console.Storage.Local/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's 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 71b15597..f31fd49d 100644 --- a/Console.Storage.Local/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Storage.Local/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Console.Storage.Local/AGENTS.md b/Console.Storage.Local/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Console.Storage.Local/AGENTS.md +++ b/Console.Storage.Local/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Console.Workers/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Console.Workers/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Console.Workers/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Console.Workers/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's diff --git a/Console.Workers/.claude/skills/nano-add-entity/SKILL.md b/Console.Workers/.claude/skills/nano-add-entity/SKILL.md index 46b8a48c..cb94fd63 100644 --- a/Console.Workers/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Workers/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 100644 --- a/Console.Workers/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Console.Workers/.claude/skills/nano-add-postman-collection/SKILL.md @@ -163,6 +163,13 @@ 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`/ diff --git a/Console.Workers/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Console.Workers/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Console.Workers/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Console.Workers/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's diff --git a/Console.Workers/.github/prompts/nano-add-entity.prompt.md b/Console.Workers/.github/prompts/nano-add-entity.prompt.md index 71b15597..f31fd49d 100644 --- a/Console.Workers/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Workers/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Console.Workers/AGENTS.md b/Console.Workers/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Console.Workers/AGENTS.md +++ b/Console.Workers/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Console._Blank/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Console._Blank/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Console._Blank/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Console._Blank/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's diff --git a/Console._Blank/.claude/skills/nano-add-entity/SKILL.md b/Console._Blank/.claude/skills/nano-add-entity/SKILL.md index 46b8a48c..cb94fd63 100644 --- a/Console._Blank/.claude/skills/nano-add-entity/SKILL.md +++ b/Console._Blank/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 100644 --- a/Console._Blank/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Console._Blank/.claude/skills/nano-add-postman-collection/SKILL.md @@ -163,6 +163,13 @@ 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`/ diff --git a/Console._Blank/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Console._Blank/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Console._Blank/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Console._Blank/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's diff --git a/Console._Blank/.github/prompts/nano-add-entity.prompt.md b/Console._Blank/.github/prompts/nano-add-entity.prompt.md index 71b15597..f31fd49d 100644 --- a/Console._Blank/.github/prompts/nano-add-entity.prompt.md +++ b/Console._Blank/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Console._Blank/AGENTS.md b/Console._Blank/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Console._Blank/AGENTS.md +++ b/Console._Blank/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with diff --git a/Web._Blank/.claude/skills/nano-add-authentication-external-custom/SKILL.md b/Web._Blank/.claude/skills/nano-add-authentication-external-custom/SKILL.md index 57964535..a809ed19 100644 --- a/Web._Blank/.claude/skills/nano-add-authentication-external-custom/SKILL.md +++ b/Web._Blank/.claude/skills/nano-add-authentication-external-custom/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-authentication-external-custom -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook — see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) — and what the provider should be called — then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples — not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type — nothing to add to `.ConfigureServi **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs — the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs — the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names — the user fills those in once they +body as the `// Placeholder` above rather than guessing field names — the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields — Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output — advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output — advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created — the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) — including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) — including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in — and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** — transient's two routes, or persistent's diff --git a/Web._Blank/.claude/skills/nano-add-entity/SKILL.md b/Web._Blank/.claude/skills/nano-add-entity/SKILL.md index 46b8a48c..cb94fd63 100644 --- a/Web._Blank/.claude/skills/nano-add-entity/SKILL.md +++ b/Web._Blank/.claude/skills/nano-add-entity/SKILL.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 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 311a1bb7..b19d3cd2 100644 --- a/Web._Blank/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Web._Blank/.claude/skills/nano-add-postman-collection/SKILL.md @@ -163,6 +163,13 @@ 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`/ diff --git a/Web._Blank/.github/prompts/nano-add-authentication-external-custom.prompt.md b/Web._Blank/.github/prompts/nano-add-authentication-external-custom.prompt.md index 370f29d1..75feafe3 100644 --- a/Web._Blank/.github/prompts/nano-add-authentication-external-custom.prompt.md +++ b/Web._Blank/.github/prompts/nano-add-authentication-external-custom.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + TODO markers), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. +description: Scaffold a custom BaseAuthExternalRepository external-login provider on a Nano.Library-based API/Web application, for a provider Nano has no built-in support for (not Microsoft/Google/Facebook - see nano-add-authentication-microsoft for that one, and AGENTS.md's Authentication section for why the other two stay config-only). Asks whether the provider's OAuth flow is AuthCodeFlow, ImplicitFlow (Nano's two built-in BaseAuthFlow shapes), or a custom class the user derives from BaseAuthFlow themselves (scaffolded too, if needed) - and what the provider should be called - then generates the repository class as a placeholder shell (mocked return values + placeholder comments), matching the class shape used throughout Nano.Lessons' external-custom-auth examples - not a working integration against any real provider API, since this skill can't know one specific provider's token/userinfo API shape. Requires nano-add-authentication-jwt already configured (Jwt + AuthController). Use when the user asks to add a custom, third-party, or in-house external login provider to a Nano API or Web application. --- # Nano add custom external authentication @@ -22,7 +22,7 @@ the generated class is discovered by type - nothing to add to `.ConfigureService **Output is a placeholder shell, not a working integration.** This skill cannot know a specific provider's real token-exchange or userinfo-lookup API shape, so `AuthenticateAsync` returns -static, clearly-fake data with a `// TODO` marking where the real HTTP call belongs - the same +static, clearly-fake data with a `// Placeholder` marking where the real HTTP call belongs - the same shape as Nano.Lessons' external-custom-auth examples. Say this plainly when done: the app will build and the new endpoint(s) will respond, but signing in through them won't yet produce a real user identity from any actual external service. @@ -98,7 +98,7 @@ Conventionally alongside the repository class, e.g. `Authentication/{Provider}Fl ```csharp public class {Provider}Flow : BaseAuthFlow { - // TODO: add whatever properties {Provider}'s own flow actually carries - mirroring + // Placeholder: add whatever properties {Provider}'s own flow actually carries - mirroring // AuthCodeFlow's Code/CodeVerifier/RedirectUri or ImplicitFlow's AccessToken shape, // each `[Required] public virtual required {Type} {Name} { get; set; }`. } @@ -106,7 +106,7 @@ public class {Provider}Flow : BaseAuthFlow Use this class (not `AuthCodeFlow`/`ImplicitFlow`) as `{TFlow}` in the repository class below. Since this skill doesn't know what properties the provider's flow actually needs, leave the class -body as the `// TODO` above rather than guessing field names - the user fills those in once they +body as the `// Placeholder` above rather than guessing field names - the user fills those in once they know exactly what the provider's handshake sends. ## The repository class @@ -120,7 +120,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} { public override async Task AuthenticateAsync({TFlow} flow, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s own token-exchange/userinfo API using the data on `flow` + // Placeholder: call {Provider}'s own token-exchange/userinfo API using the data on `flow` // ({TFlow} fields - Code/CodeVerifier/RedirectUri for AuthCodeFlow, AccessToken for // ImplicitFlow, or whatever properties a custom flow class carries), and map its // response onto ExternalAuthenticationData below. @@ -149,7 +149,7 @@ public class {Provider}ExternalRepository() : BaseAuthExternalRepository<{TFlow} public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - // TODO: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its + // Placeholder: call {Provider}'s token-refresh endpoint using `refreshToken`, and map its // response onto ExternalAuthenticationToken below. await Task.CompletedTask; @@ -183,7 +183,7 @@ dead code just because the app is persistent. ## Wiring real provider credentials later -Not part of this skill's own output - advisory only, for when the `// TODO` above gets filled in +Not part of this skill's own output - advisory only, for when the `// Placeholder` above gets filled in for real. At that point the provider will need its own credentials (an API key, a client id/secret, a base URL) passed into the repository's constructor. Follow AGENTS.md's `### Custom Configuration Section` pattern: a plain options class bound via `AddNanoConfigSection`, @@ -199,7 +199,7 @@ pattern is the common answer) rather than assuming a convention. - Show the file(s) created - the repository class, and the custom flow class too if step 3 needed one. - State the flow type chosen and why (matching what the user said about the provider's real OAuth - shape) - including, if a custom class was scaffolded, that its properties are left as a `// TODO` + shape) - including, if a custom class was scaffolded, that its properties are left as a `// Placeholder` for the user to fill in - and the refresh decision (implemented for real later vs. explicitly unsupported). - **State the actual, resulting endpoint set from step 5** - transient's two routes, or persistent's diff --git a/Web._Blank/.github/prompts/nano-add-entity.prompt.md b/Web._Blank/.github/prompts/nano-add-entity.prompt.md index 71b15597..f31fd49d 100644 --- a/Web._Blank/.github/prompts/nano-add-entity.prompt.md +++ b/Web._Blank/.github/prompts/nano-add-entity.prompt.md @@ -142,6 +142,16 @@ public class : BaseEntity `.Ignore(...)` in the mapping), and have the query criteria translate a filter on it into conditions on the underlying columns. A stored status column for this goes stale, and a database generated column can't reference the current time. +- **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 + 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 + 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 diff --git a/Web._Blank/AGENTS.md b/Web._Blank/AGENTS.md index df7d8c25..664fe5dd 100644 --- a/Web._Blank/AGENTS.md +++ b/Web._Blank/AGENTS.md @@ -2243,6 +2243,15 @@ through its Api Client, never added to an app playing the [Public API role](#pub Conventionally placed in a `Controllers/` folder in the application project (not a hard requirement). +#### XML doc comments + +Controller `///` comments feed the public Swagger, so keep them short and safe to publish: + +- One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. + #### Request Validation Automatic on any controller deriving from `BaseController` — a failing model returns `400 Bad Request` with From fac43f91b9d8eec1aef64d34e0f43154138fc1ec Mon Sep 17 00:00:00 2001 From: vivet Date: Fri, 2 Oct 2026 11:42:27 +0200 Subject: [PATCH 12/13] Updated --- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Api.ApiClients.Audit/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Api.ApiClients.Entity/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Api.ApiClients.RootLogIn/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Api.ApiClients/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Api.Auth.External.Custom/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Api.Auth.External.Microsoft/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Api.Auth.RootLogin/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Api.Authorization/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Api.ContentNegotiation/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Api.Cookies/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Api.CustomConfigSection/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Api.CustomMiddleware/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Api.CustomService/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Api.Data.Audit/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Api.Data.EntityEvents/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Api.Data.Identity.Auth.ApiKey/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Api.Data.Identity.Auth.External.Custom/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Api.Data.Identity.Auth.Jwt/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Api.Data.Identity/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Api.Data.InMemory/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Api.Data.LazyLoading/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Api.Data.MySql.Collation/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Api.Data.MySql.Mappings/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Api.Data.MySql.Spatial/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Api.Data.MySql.StoredProcedures/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Api.Data.MySql.Views/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Api.Data.MySql/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Api.Data.PostgreSQL.Spatial/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Api.Data.PostgreSQL/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Api.Data.Repository.AutoSave/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Api.Data.Repository.Includes/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Api.Data.SoftDelete/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Api.Data.SqLite/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Api.Data.SqlServer.Spatial/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Api.Data.SqlServer/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Api.Data.Triggers/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Api.Documentation.Csp/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Api.Documentation/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Api.ErrorHandling/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Api.Eventing.RabbitMq/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Api.HealthChecks/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Api.Hosting.Http/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Api.Hosting.Https/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Api.Hosting.MultipartLimits/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Api.Localization/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Api.Logging.Log4Net/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Api.Logging.Microsoft/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Api.Logging.NLog/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Api.Logging.Serilog/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Api.Metrics/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Api.MultipartJson/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Api.PolicyHeaders.ContentSecurityPolicy/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Api.PolicyHeaders.ContentTypeOptions/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Api.PolicyHeaders.Cors/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Api.PolicyHeaders.ForwardedHeaders/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Api.PolicyHeaders.FrameOptions/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Api.PolicyHeaders.Hsts/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Api.PolicyHeaders.ReferrerPolicy/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Api.PolicyHeaders.Robots/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Api.PolicyHeaders.XssProtection/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Api.RequestTracing/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Api.ResponseCache/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Api.ResponseCompression/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Api.Session/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Api.StartupTasks/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Api.StaticFiles/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Api.Storage.Azure/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Api.Storage.Local/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Api.TimeZone/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Api.Versioning/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Api.VirusScan/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Api._Blank/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Console.CustomConfigSection/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Console.CustomService/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Console.Data.InMemory/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Console.Data.MySql/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Console.Data.PostgreSQL/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Console.Data.SqLite/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Console.Data.SqlServer/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Console.Eventing.RabbitMq/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Console.ExceptionHandling/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Console.Localization/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Console.Logging.Log4Net/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Console.Logging.Microsoft/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Console.Logging.NLog/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Console.Logging.Serilog/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Console.StartupTasks/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Console.Storage.Azure/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Console.Storage.Local/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Console.Workers/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Console._Blank/AGENTS.md | 11 +++++++---- .../skills/nano-add-postman-collection/SKILL.md | 9 ++++++--- Web._Blank/AGENTS.md | 11 +++++++---- 184 files changed, 1196 insertions(+), 644 deletions(-) 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 b19d3cd2..8bd35093 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 @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Api.ApiClients.Audit/AGENTS.md b/Api.ApiClients.Audit/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Api.ApiClients.Audit/AGENTS.md +++ b/Api.ApiClients.Audit/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 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 @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Api.ApiClients.Entity/AGENTS.md b/Api.ApiClients.Entity/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Api.ApiClients.Entity/AGENTS.md +++ b/Api.ApiClients.Entity/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 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 @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Api.ApiClients.RootLogIn/AGENTS.md b/Api.ApiClients.RootLogIn/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Api.ApiClients.RootLogIn/AGENTS.md +++ b/Api.ApiClients.RootLogIn/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 100644 --- a/Api.ApiClients/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.ApiClients/.claude/skills/nano-add-postman-collection/SKILL.md @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Api.ApiClients/AGENTS.md b/Api.ApiClients/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Api.ApiClients/AGENTS.md +++ b/Api.ApiClients/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 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 @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Api.Auth.External.Custom/AGENTS.md b/Api.Auth.External.Custom/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Api.Auth.External.Custom/AGENTS.md +++ b/Api.Auth.External.Custom/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 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 @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Api.Auth.External.Microsoft/AGENTS.md b/Api.Auth.External.Microsoft/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Api.Auth.External.Microsoft/AGENTS.md +++ b/Api.Auth.External.Microsoft/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 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 @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Api.Auth.RootLogin/AGENTS.md b/Api.Auth.RootLogin/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Api.Auth.RootLogin/AGENTS.md +++ b/Api.Auth.RootLogin/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 100644 --- a/Api.Authorization/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Authorization/.claude/skills/nano-add-postman-collection/SKILL.md @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Api.Authorization/AGENTS.md b/Api.Authorization/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Api.Authorization/AGENTS.md +++ b/Api.Authorization/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 100644 --- a/Api.ContentNegotiation/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.ContentNegotiation/.claude/skills/nano-add-postman-collection/SKILL.md @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Api.ContentNegotiation/AGENTS.md b/Api.ContentNegotiation/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Api.ContentNegotiation/AGENTS.md +++ b/Api.ContentNegotiation/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 100644 --- a/Api.Cookies/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Cookies/.claude/skills/nano-add-postman-collection/SKILL.md @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Api.Cookies/AGENTS.md b/Api.Cookies/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Api.Cookies/AGENTS.md +++ b/Api.Cookies/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 100644 --- a/Api.CustomConfigSection/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.CustomConfigSection/.claude/skills/nano-add-postman-collection/SKILL.md @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Api.CustomConfigSection/AGENTS.md b/Api.CustomConfigSection/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Api.CustomConfigSection/AGENTS.md +++ b/Api.CustomConfigSection/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 100644 --- a/Api.CustomMiddleware/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.CustomMiddleware/.claude/skills/nano-add-postman-collection/SKILL.md @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Api.CustomMiddleware/AGENTS.md b/Api.CustomMiddleware/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Api.CustomMiddleware/AGENTS.md +++ b/Api.CustomMiddleware/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 100644 --- a/Api.CustomService/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.CustomService/.claude/skills/nano-add-postman-collection/SKILL.md @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Api.CustomService/AGENTS.md b/Api.CustomService/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Api.CustomService/AGENTS.md +++ b/Api.CustomService/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 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 @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Api.Data.Audit/AGENTS.md b/Api.Data.Audit/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Api.Data.Audit/AGENTS.md +++ b/Api.Data.Audit/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 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 @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Api.Data.EntityEvents/AGENTS.md b/Api.Data.EntityEvents/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Api.Data.EntityEvents/AGENTS.md +++ b/Api.Data.EntityEvents/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 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 @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Api.Data.Identity.Auth.ApiKey/AGENTS.md b/Api.Data.Identity.Auth.ApiKey/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Api.Data.Identity.Auth.ApiKey/AGENTS.md +++ b/Api.Data.Identity.Auth.ApiKey/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 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 @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Api.Data.Identity.Auth.External.Custom/AGENTS.md b/Api.Data.Identity.Auth.External.Custom/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Api.Data.Identity.Auth.External.Custom/AGENTS.md +++ b/Api.Data.Identity.Auth.External.Custom/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 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 @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Api.Data.Identity.Auth.Jwt/AGENTS.md b/Api.Data.Identity.Auth.Jwt/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Api.Data.Identity.Auth.Jwt/AGENTS.md +++ b/Api.Data.Identity.Auth.Jwt/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 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 @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Api.Data.Identity/AGENTS.md b/Api.Data.Identity/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Api.Data.Identity/AGENTS.md +++ b/Api.Data.Identity/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 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 @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Api.Data.InMemory/AGENTS.md b/Api.Data.InMemory/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Api.Data.InMemory/AGENTS.md +++ b/Api.Data.InMemory/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 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 @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Api.Data.LazyLoading/AGENTS.md b/Api.Data.LazyLoading/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Api.Data.LazyLoading/AGENTS.md +++ b/Api.Data.LazyLoading/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 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 @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Api.Data.MySql.Collation/AGENTS.md b/Api.Data.MySql.Collation/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Api.Data.MySql.Collation/AGENTS.md +++ b/Api.Data.MySql.Collation/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 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 @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Api.Data.MySql.Mappings/AGENTS.md b/Api.Data.MySql.Mappings/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Api.Data.MySql.Mappings/AGENTS.md +++ b/Api.Data.MySql.Mappings/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 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 @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Api.Data.MySql.Spatial/AGENTS.md b/Api.Data.MySql.Spatial/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Api.Data.MySql.Spatial/AGENTS.md +++ b/Api.Data.MySql.Spatial/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 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 @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Api.Data.MySql.StoredProcedures/AGENTS.md b/Api.Data.MySql.StoredProcedures/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Api.Data.MySql.StoredProcedures/AGENTS.md +++ b/Api.Data.MySql.StoredProcedures/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 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 @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Api.Data.MySql.Views/AGENTS.md b/Api.Data.MySql.Views/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Api.Data.MySql.Views/AGENTS.md +++ b/Api.Data.MySql.Views/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 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 @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Api.Data.MySql/AGENTS.md b/Api.Data.MySql/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Api.Data.MySql/AGENTS.md +++ b/Api.Data.MySql/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 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 @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Api.Data.PostgreSQL.Spatial/AGENTS.md b/Api.Data.PostgreSQL.Spatial/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Api.Data.PostgreSQL.Spatial/AGENTS.md +++ b/Api.Data.PostgreSQL.Spatial/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 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 @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Api.Data.PostgreSQL/AGENTS.md b/Api.Data.PostgreSQL/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Api.Data.PostgreSQL/AGENTS.md +++ b/Api.Data.PostgreSQL/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 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 @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Api.Data.Repository.AutoSave/AGENTS.md b/Api.Data.Repository.AutoSave/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Api.Data.Repository.AutoSave/AGENTS.md +++ b/Api.Data.Repository.AutoSave/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 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 @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Api.Data.Repository.Includes/AGENTS.md b/Api.Data.Repository.Includes/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Api.Data.Repository.Includes/AGENTS.md +++ b/Api.Data.Repository.Includes/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 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 @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Api.Data.SoftDelete/AGENTS.md b/Api.Data.SoftDelete/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Api.Data.SoftDelete/AGENTS.md +++ b/Api.Data.SoftDelete/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 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 @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Api.Data.SqLite/AGENTS.md b/Api.Data.SqLite/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Api.Data.SqLite/AGENTS.md +++ b/Api.Data.SqLite/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 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 @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Api.Data.SqlServer.Spatial/AGENTS.md b/Api.Data.SqlServer.Spatial/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Api.Data.SqlServer.Spatial/AGENTS.md +++ b/Api.Data.SqlServer.Spatial/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 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 @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Api.Data.SqlServer/AGENTS.md b/Api.Data.SqlServer/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Api.Data.SqlServer/AGENTS.md +++ b/Api.Data.SqlServer/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 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 @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Api.Data.Triggers/AGENTS.md b/Api.Data.Triggers/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Api.Data.Triggers/AGENTS.md +++ b/Api.Data.Triggers/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 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 @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Api.Documentation.Csp/AGENTS.md b/Api.Documentation.Csp/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Api.Documentation.Csp/AGENTS.md +++ b/Api.Documentation.Csp/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 100644 --- a/Api.Documentation/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Documentation/.claude/skills/nano-add-postman-collection/SKILL.md @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Api.Documentation/AGENTS.md b/Api.Documentation/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Api.Documentation/AGENTS.md +++ b/Api.Documentation/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 100644 --- a/Api.ErrorHandling/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.ErrorHandling/.claude/skills/nano-add-postman-collection/SKILL.md @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Api.ErrorHandling/AGENTS.md b/Api.ErrorHandling/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Api.ErrorHandling/AGENTS.md +++ b/Api.ErrorHandling/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 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 @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Api.Eventing.RabbitMq/AGENTS.md b/Api.Eventing.RabbitMq/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Api.Eventing.RabbitMq/AGENTS.md +++ b/Api.Eventing.RabbitMq/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 100644 --- a/Api.HealthChecks/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.HealthChecks/.claude/skills/nano-add-postman-collection/SKILL.md @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Api.HealthChecks/AGENTS.md b/Api.HealthChecks/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Api.HealthChecks/AGENTS.md +++ b/Api.HealthChecks/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 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 @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Api.Hosting.Http/AGENTS.md b/Api.Hosting.Http/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Api.Hosting.Http/AGENTS.md +++ b/Api.Hosting.Http/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 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 @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Api.Hosting.Https/AGENTS.md b/Api.Hosting.Https/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Api.Hosting.Https/AGENTS.md +++ b/Api.Hosting.Https/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 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 @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Api.Hosting.MultipartLimits/AGENTS.md b/Api.Hosting.MultipartLimits/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Api.Hosting.MultipartLimits/AGENTS.md +++ b/Api.Hosting.MultipartLimits/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 100644 --- a/Api.Localization/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Localization/.claude/skills/nano-add-postman-collection/SKILL.md @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Api.Localization/AGENTS.md b/Api.Localization/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Api.Localization/AGENTS.md +++ b/Api.Localization/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 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 @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Api.Logging.Log4Net/AGENTS.md b/Api.Logging.Log4Net/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Api.Logging.Log4Net/AGENTS.md +++ b/Api.Logging.Log4Net/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 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 @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Api.Logging.Microsoft/AGENTS.md b/Api.Logging.Microsoft/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Api.Logging.Microsoft/AGENTS.md +++ b/Api.Logging.Microsoft/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 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 @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Api.Logging.NLog/AGENTS.md b/Api.Logging.NLog/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Api.Logging.NLog/AGENTS.md +++ b/Api.Logging.NLog/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 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 @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Api.Logging.Serilog/AGENTS.md b/Api.Logging.Serilog/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Api.Logging.Serilog/AGENTS.md +++ b/Api.Logging.Serilog/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 100644 --- a/Api.Metrics/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Metrics/.claude/skills/nano-add-postman-collection/SKILL.md @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Api.Metrics/AGENTS.md b/Api.Metrics/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Api.Metrics/AGENTS.md +++ b/Api.Metrics/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 100644 --- a/Api.MultipartJson/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.MultipartJson/.claude/skills/nano-add-postman-collection/SKILL.md @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Api.MultipartJson/AGENTS.md b/Api.MultipartJson/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Api.MultipartJson/AGENTS.md +++ b/Api.MultipartJson/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 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 @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Api.PolicyHeaders.ContentSecurityPolicy/AGENTS.md b/Api.PolicyHeaders.ContentSecurityPolicy/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Api.PolicyHeaders.ContentSecurityPolicy/AGENTS.md +++ b/Api.PolicyHeaders.ContentSecurityPolicy/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 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 @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Api.PolicyHeaders.ContentTypeOptions/AGENTS.md b/Api.PolicyHeaders.ContentTypeOptions/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Api.PolicyHeaders.ContentTypeOptions/AGENTS.md +++ b/Api.PolicyHeaders.ContentTypeOptions/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 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 @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Api.PolicyHeaders.Cors/AGENTS.md b/Api.PolicyHeaders.Cors/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Api.PolicyHeaders.Cors/AGENTS.md +++ b/Api.PolicyHeaders.Cors/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 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 @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Api.PolicyHeaders.ForwardedHeaders/AGENTS.md b/Api.PolicyHeaders.ForwardedHeaders/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Api.PolicyHeaders.ForwardedHeaders/AGENTS.md +++ b/Api.PolicyHeaders.ForwardedHeaders/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 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 @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Api.PolicyHeaders.FrameOptions/AGENTS.md b/Api.PolicyHeaders.FrameOptions/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Api.PolicyHeaders.FrameOptions/AGENTS.md +++ b/Api.PolicyHeaders.FrameOptions/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 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 @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Api.PolicyHeaders.Hsts/AGENTS.md b/Api.PolicyHeaders.Hsts/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Api.PolicyHeaders.Hsts/AGENTS.md +++ b/Api.PolicyHeaders.Hsts/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 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 @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Api.PolicyHeaders.ReferrerPolicy/AGENTS.md b/Api.PolicyHeaders.ReferrerPolicy/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Api.PolicyHeaders.ReferrerPolicy/AGENTS.md +++ b/Api.PolicyHeaders.ReferrerPolicy/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 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 @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Api.PolicyHeaders.Robots/AGENTS.md b/Api.PolicyHeaders.Robots/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Api.PolicyHeaders.Robots/AGENTS.md +++ b/Api.PolicyHeaders.Robots/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 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 @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Api.PolicyHeaders.XssProtection/AGENTS.md b/Api.PolicyHeaders.XssProtection/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Api.PolicyHeaders.XssProtection/AGENTS.md +++ b/Api.PolicyHeaders.XssProtection/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 100644 --- a/Api.RequestTracing/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.RequestTracing/.claude/skills/nano-add-postman-collection/SKILL.md @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Api.RequestTracing/AGENTS.md b/Api.RequestTracing/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Api.RequestTracing/AGENTS.md +++ b/Api.RequestTracing/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 100644 --- a/Api.ResponseCache/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.ResponseCache/.claude/skills/nano-add-postman-collection/SKILL.md @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Api.ResponseCache/AGENTS.md b/Api.ResponseCache/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Api.ResponseCache/AGENTS.md +++ b/Api.ResponseCache/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 100644 --- a/Api.ResponseCompression/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.ResponseCompression/.claude/skills/nano-add-postman-collection/SKILL.md @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Api.ResponseCompression/AGENTS.md b/Api.ResponseCompression/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Api.ResponseCompression/AGENTS.md +++ b/Api.ResponseCompression/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 100644 --- a/Api.Session/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Session/.claude/skills/nano-add-postman-collection/SKILL.md @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Api.Session/AGENTS.md b/Api.Session/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Api.Session/AGENTS.md +++ b/Api.Session/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 100644 --- a/Api.StartupTasks/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.StartupTasks/.claude/skills/nano-add-postman-collection/SKILL.md @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Api.StartupTasks/AGENTS.md b/Api.StartupTasks/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Api.StartupTasks/AGENTS.md +++ b/Api.StartupTasks/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 100644 --- a/Api.StaticFiles/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.StaticFiles/.claude/skills/nano-add-postman-collection/SKILL.md @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Api.StaticFiles/AGENTS.md b/Api.StaticFiles/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Api.StaticFiles/AGENTS.md +++ b/Api.StaticFiles/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 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 @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Api.Storage.Azure/AGENTS.md b/Api.Storage.Azure/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Api.Storage.Azure/AGENTS.md +++ b/Api.Storage.Azure/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 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 @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Api.Storage.Local/AGENTS.md b/Api.Storage.Local/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Api.Storage.Local/AGENTS.md +++ b/Api.Storage.Local/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 100644 --- a/Api.TimeZone/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.TimeZone/.claude/skills/nano-add-postman-collection/SKILL.md @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Api.TimeZone/AGENTS.md b/Api.TimeZone/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Api.TimeZone/AGENTS.md +++ b/Api.TimeZone/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 100644 --- a/Api.Versioning/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Versioning/.claude/skills/nano-add-postman-collection/SKILL.md @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Api.Versioning/AGENTS.md b/Api.Versioning/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Api.Versioning/AGENTS.md +++ b/Api.Versioning/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 100644 --- a/Api.VirusScan/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.VirusScan/.claude/skills/nano-add-postman-collection/SKILL.md @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Api.VirusScan/AGENTS.md b/Api.VirusScan/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Api.VirusScan/AGENTS.md +++ b/Api.VirusScan/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 100644 --- a/Api._Blank/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api._Blank/.claude/skills/nano-add-postman-collection/SKILL.md @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Api._Blank/AGENTS.md b/Api._Blank/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Api._Blank/AGENTS.md +++ b/Api._Blank/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 100644 --- a/Console.CustomConfigSection/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Console.CustomConfigSection/.claude/skills/nano-add-postman-collection/SKILL.md @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Console.CustomConfigSection/AGENTS.md b/Console.CustomConfigSection/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Console.CustomConfigSection/AGENTS.md +++ b/Console.CustomConfigSection/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 100644 --- a/Console.CustomService/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Console.CustomService/.claude/skills/nano-add-postman-collection/SKILL.md @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Console.CustomService/AGENTS.md b/Console.CustomService/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Console.CustomService/AGENTS.md +++ b/Console.CustomService/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 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 @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Console.Data.InMemory/AGENTS.md b/Console.Data.InMemory/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Console.Data.InMemory/AGENTS.md +++ b/Console.Data.InMemory/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 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 @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Console.Data.MySql/AGENTS.md b/Console.Data.MySql/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Console.Data.MySql/AGENTS.md +++ b/Console.Data.MySql/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 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 @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Console.Data.PostgreSQL/AGENTS.md b/Console.Data.PostgreSQL/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Console.Data.PostgreSQL/AGENTS.md +++ b/Console.Data.PostgreSQL/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 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 @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Console.Data.SqLite/AGENTS.md b/Console.Data.SqLite/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Console.Data.SqLite/AGENTS.md +++ b/Console.Data.SqLite/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 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 @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Console.Data.SqlServer/AGENTS.md b/Console.Data.SqlServer/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Console.Data.SqlServer/AGENTS.md +++ b/Console.Data.SqlServer/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 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 @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Console.Eventing.RabbitMq/AGENTS.md b/Console.Eventing.RabbitMq/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Console.Eventing.RabbitMq/AGENTS.md +++ b/Console.Eventing.RabbitMq/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 100644 --- a/Console.ExceptionHandling/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Console.ExceptionHandling/.claude/skills/nano-add-postman-collection/SKILL.md @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Console.ExceptionHandling/AGENTS.md b/Console.ExceptionHandling/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Console.ExceptionHandling/AGENTS.md +++ b/Console.ExceptionHandling/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 100644 --- a/Console.Localization/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Console.Localization/.claude/skills/nano-add-postman-collection/SKILL.md @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Console.Localization/AGENTS.md b/Console.Localization/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Console.Localization/AGENTS.md +++ b/Console.Localization/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 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 @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Console.Logging.Log4Net/AGENTS.md b/Console.Logging.Log4Net/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Console.Logging.Log4Net/AGENTS.md +++ b/Console.Logging.Log4Net/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 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 @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Console.Logging.Microsoft/AGENTS.md b/Console.Logging.Microsoft/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Console.Logging.Microsoft/AGENTS.md +++ b/Console.Logging.Microsoft/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 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 @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Console.Logging.NLog/AGENTS.md b/Console.Logging.NLog/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Console.Logging.NLog/AGENTS.md +++ b/Console.Logging.NLog/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 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 @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Console.Logging.Serilog/AGENTS.md b/Console.Logging.Serilog/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Console.Logging.Serilog/AGENTS.md +++ b/Console.Logging.Serilog/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 100644 --- a/Console.StartupTasks/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Console.StartupTasks/.claude/skills/nano-add-postman-collection/SKILL.md @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Console.StartupTasks/AGENTS.md b/Console.StartupTasks/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Console.StartupTasks/AGENTS.md +++ b/Console.StartupTasks/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 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 @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Console.Storage.Azure/AGENTS.md b/Console.Storage.Azure/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Console.Storage.Azure/AGENTS.md +++ b/Console.Storage.Azure/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 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 @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Console.Storage.Local/AGENTS.md b/Console.Storage.Local/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Console.Storage.Local/AGENTS.md +++ b/Console.Storage.Local/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 100644 --- a/Console.Workers/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Console.Workers/.claude/skills/nano-add-postman-collection/SKILL.md @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Console.Workers/AGENTS.md b/Console.Workers/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Console.Workers/AGENTS.md +++ b/Console.Workers/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 100644 --- a/Console._Blank/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Console._Blank/.claude/skills/nano-add-postman-collection/SKILL.md @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Console._Blank/AGENTS.md b/Console._Blank/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Console._Blank/AGENTS.md +++ b/Console._Blank/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. 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 b19d3cd2..8bd35093 100644 --- a/Web._Blank/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Web._Blank/.claude/skills/nano-add-postman-collection/SKILL.md @@ -243,9 +243,12 @@ 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 - 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 + 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 diff --git a/Web._Blank/AGENTS.md b/Web._Blank/AGENTS.md index 664fe5dd..76ff32fd 100644 --- a/Web._Blank/AGENTS.md +++ b/Web._Blank/AGENTS.md @@ -2248,6 +2248,7 @@ Conventionally placed in a `Controllers/` folder in the application project (not Controller `///` comments feed the public Swagger, so keep them short and safe to publish: - One line `` that says what the action does (`Gets a list of assets.`, `Deletes a non archived order.`), and short ``/``/`` text. Never an essay. +- 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. @@ -2940,13 +2941,15 @@ the matching capability interface from [Data Models](#data-models): | Delete | `DeleteAsync(id)` (+ shorthand overloads), `DeleteAsync(entity)`, `DeleteManyAsync` (by keys/entities/criteria/predicate), `DeleteManyBulkAsync` (criteria/predicate variants use native `ExecuteDeleteAsync`, no paid package) | `IEntityDeletable` | | Aggregate | `CountAsync`, `SumAsync`/`AverageAsync` (hard-coded `decimal`) | `IEntity` | | Raw SQL | `ExecuteProcedureAsync`, `ExecuteProcedureListAsync`, `ExecuteProcedureScalarAsync` | — | -| Persistence | `SaveChangesAsync(ct)` | — | +| Persistence | `ExecuteInTransactionAsync(action, ct)`, `SaveChangesAsync(ct)` | — | #### Transactions and `UseAutoSave` -`Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To batch -several repository calls into one transaction, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` -once yourself at the end — `IRepository` has no explicit `BeginTransaction`/`Commit` API of its own. +`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: +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 before they can reload it with `[Include]`d navigations populated. From feca5d702423c9a872cff829f8c683396fc805f7 Mon Sep 17 00:00:00 2001 From: vivet Date: Sat, 3 Oct 2026 12:21:21 +0200 Subject: [PATCH 13/13] Updated --- .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Api.ApiClients.Audit/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Api.ApiClients.Entity/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Api.ApiClients.RootLogIn/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Api.ApiClients/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Api.Auth.External.Custom/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Api.Auth.External.Microsoft/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Api.Auth.RootLogin/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Api.Authorization/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Api.ContentNegotiation/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Api.Cookies/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Api.CustomConfigSection/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Api.CustomMiddleware/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Api.CustomService/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Api.Data.Audit/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Api.Data.EntityEvents/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Api.Data.Identity.Auth.ApiKey/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ .../AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Api.Data.Identity.Auth.Jwt/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Api.Data.Identity/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Api.Data.InMemory/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Api.Data.LazyLoading/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Api.Data.MySql.Collation/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Api.Data.MySql.Mappings/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Api.Data.MySql.Spatial/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Api.Data.MySql.StoredProcedures/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Api.Data.MySql.Views/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Api.Data.MySql/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Api.Data.PostgreSQL.Spatial/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Api.Data.PostgreSQL/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Api.Data.Repository.AutoSave/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Api.Data.Repository.Includes/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Api.Data.SoftDelete/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Api.Data.SqLite/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Api.Data.SqlServer.Spatial/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Api.Data.SqlServer/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Api.Data.Triggers/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Api.Documentation.Csp/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Api.Documentation/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Api.ErrorHandling/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Api.Eventing.RabbitMq/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Api.HealthChecks/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Api.Hosting.Http/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Api.Hosting.Https/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Api.Hosting.MultipartLimits/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Api.Localization/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Api.Logging.Log4Net/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Api.Logging.Microsoft/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Api.Logging.NLog/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Api.Logging.Serilog/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Api.Metrics/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Api.MultipartJson/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ .../AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ .../AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Api.PolicyHeaders.Cors/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Api.PolicyHeaders.ForwardedHeaders/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Api.PolicyHeaders.FrameOptions/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Api.PolicyHeaders.Hsts/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Api.PolicyHeaders.ReferrerPolicy/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Api.PolicyHeaders.Robots/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Api.PolicyHeaders.XssProtection/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Api.RequestTracing/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Api.ResponseCache/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Api.ResponseCompression/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Api.Session/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Api.StartupTasks/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Api.StaticFiles/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Api.Storage.Azure/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Api.Storage.Local/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Api.TimeZone/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Api.Versioning/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Api.VirusScan/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Api._Blank/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Console.CustomConfigSection/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Console.CustomService/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Console.Data.InMemory/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Console.Data.MySql/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Console.Data.PostgreSQL/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Console.Data.SqLite/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Console.Data.SqlServer/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Console.Eventing.RabbitMq/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Console.ExceptionHandling/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Console.Localization/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Console.Logging.Log4Net/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Console.Logging.Microsoft/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Console.Logging.NLog/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Console.Logging.Serilog/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Console.StartupTasks/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Console.Storage.Azure/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Console.Storage.Local/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Console.Workers/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Console._Blank/AGENTS.md | 40 +++++++++++++++++++ .../skills/nano-add-custom-endpoint/SKILL.md | 14 +++++++ .../nano-add-custom-endpoint.prompt.md | 14 +++++++ Web._Blank/AGENTS.md | 40 +++++++++++++++++++ 276 files changed, 6256 insertions(+) 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 48e69388..f1b5f53f 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Api.ApiClients.Audit/AGENTS.md b/Api.ApiClients.Audit/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Api.ApiClients.Audit/AGENTS.md +++ b/Api.ApiClients.Audit/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Api.ApiClients.Entity/AGENTS.md b/Api.ApiClients.Entity/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Api.ApiClients.Entity/AGENTS.md +++ b/Api.ApiClients.Entity/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Api.ApiClients.RootLogIn/AGENTS.md b/Api.ApiClients.RootLogIn/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Api.ApiClients.RootLogIn/AGENTS.md +++ b/Api.ApiClients.RootLogIn/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 100644 --- a/Api.ApiClients/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.ApiClients/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 100644 --- a/Api.ApiClients/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.ApiClients/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Api.ApiClients/AGENTS.md b/Api.ApiClients/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Api.ApiClients/AGENTS.md +++ b/Api.ApiClients/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Api.Auth.External.Custom/AGENTS.md b/Api.Auth.External.Custom/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Api.Auth.External.Custom/AGENTS.md +++ b/Api.Auth.External.Custom/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Api.Auth.External.Microsoft/AGENTS.md b/Api.Auth.External.Microsoft/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Api.Auth.External.Microsoft/AGENTS.md +++ b/Api.Auth.External.Microsoft/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Api.Auth.RootLogin/AGENTS.md b/Api.Auth.RootLogin/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Api.Auth.RootLogin/AGENTS.md +++ b/Api.Auth.RootLogin/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 100644 --- a/Api.Authorization/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.Authorization/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 100644 --- a/Api.Authorization/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Authorization/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Api.Authorization/AGENTS.md b/Api.Authorization/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Api.Authorization/AGENTS.md +++ b/Api.Authorization/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 100644 --- a/Api.ContentNegotiation/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.ContentNegotiation/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 100644 --- a/Api.ContentNegotiation/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.ContentNegotiation/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Api.ContentNegotiation/AGENTS.md b/Api.ContentNegotiation/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Api.ContentNegotiation/AGENTS.md +++ b/Api.ContentNegotiation/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 100644 --- a/Api.Cookies/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.Cookies/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 100644 --- a/Api.Cookies/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Cookies/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Api.Cookies/AGENTS.md b/Api.Cookies/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Api.Cookies/AGENTS.md +++ b/Api.Cookies/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 100644 --- a/Api.CustomConfigSection/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.CustomConfigSection/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 100644 --- a/Api.CustomConfigSection/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.CustomConfigSection/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Api.CustomConfigSection/AGENTS.md b/Api.CustomConfigSection/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Api.CustomConfigSection/AGENTS.md +++ b/Api.CustomConfigSection/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 100644 --- a/Api.CustomMiddleware/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.CustomMiddleware/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 100644 --- a/Api.CustomMiddleware/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.CustomMiddleware/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Api.CustomMiddleware/AGENTS.md b/Api.CustomMiddleware/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Api.CustomMiddleware/AGENTS.md +++ b/Api.CustomMiddleware/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 100644 --- a/Api.CustomService/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.CustomService/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 100644 --- a/Api.CustomService/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.CustomService/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Api.CustomService/AGENTS.md b/Api.CustomService/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Api.CustomService/AGENTS.md +++ b/Api.CustomService/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Api.Data.Audit/AGENTS.md b/Api.Data.Audit/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Api.Data.Audit/AGENTS.md +++ b/Api.Data.Audit/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Api.Data.EntityEvents/AGENTS.md b/Api.Data.EntityEvents/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Api.Data.EntityEvents/AGENTS.md +++ b/Api.Data.EntityEvents/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Api.Data.Identity.Auth.ApiKey/AGENTS.md b/Api.Data.Identity.Auth.ApiKey/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Api.Data.Identity.Auth.ApiKey/AGENTS.md +++ b/Api.Data.Identity.Auth.ApiKey/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Api.Data.Identity.Auth.External.Custom/AGENTS.md b/Api.Data.Identity.Auth.External.Custom/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Api.Data.Identity.Auth.External.Custom/AGENTS.md +++ b/Api.Data.Identity.Auth.External.Custom/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Api.Data.Identity.Auth.Jwt/AGENTS.md b/Api.Data.Identity.Auth.Jwt/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Api.Data.Identity.Auth.Jwt/AGENTS.md +++ b/Api.Data.Identity.Auth.Jwt/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Api.Data.Identity/AGENTS.md b/Api.Data.Identity/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Api.Data.Identity/AGENTS.md +++ b/Api.Data.Identity/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Api.Data.InMemory/AGENTS.md b/Api.Data.InMemory/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Api.Data.InMemory/AGENTS.md +++ b/Api.Data.InMemory/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Api.Data.LazyLoading/AGENTS.md b/Api.Data.LazyLoading/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Api.Data.LazyLoading/AGENTS.md +++ b/Api.Data.LazyLoading/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Api.Data.MySql.Collation/AGENTS.md b/Api.Data.MySql.Collation/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Api.Data.MySql.Collation/AGENTS.md +++ b/Api.Data.MySql.Collation/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Api.Data.MySql.Mappings/AGENTS.md b/Api.Data.MySql.Mappings/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Api.Data.MySql.Mappings/AGENTS.md +++ b/Api.Data.MySql.Mappings/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Api.Data.MySql.Spatial/AGENTS.md b/Api.Data.MySql.Spatial/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Api.Data.MySql.Spatial/AGENTS.md +++ b/Api.Data.MySql.Spatial/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Api.Data.MySql.StoredProcedures/AGENTS.md b/Api.Data.MySql.StoredProcedures/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Api.Data.MySql.StoredProcedures/AGENTS.md +++ b/Api.Data.MySql.StoredProcedures/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Api.Data.MySql.Views/AGENTS.md b/Api.Data.MySql.Views/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Api.Data.MySql.Views/AGENTS.md +++ b/Api.Data.MySql.Views/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Api.Data.MySql/AGENTS.md b/Api.Data.MySql/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Api.Data.MySql/AGENTS.md +++ b/Api.Data.MySql/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Api.Data.PostgreSQL.Spatial/AGENTS.md b/Api.Data.PostgreSQL.Spatial/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Api.Data.PostgreSQL.Spatial/AGENTS.md +++ b/Api.Data.PostgreSQL.Spatial/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Api.Data.PostgreSQL/AGENTS.md b/Api.Data.PostgreSQL/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Api.Data.PostgreSQL/AGENTS.md +++ b/Api.Data.PostgreSQL/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Api.Data.Repository.AutoSave/AGENTS.md b/Api.Data.Repository.AutoSave/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Api.Data.Repository.AutoSave/AGENTS.md +++ b/Api.Data.Repository.AutoSave/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Api.Data.Repository.Includes/AGENTS.md b/Api.Data.Repository.Includes/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Api.Data.Repository.Includes/AGENTS.md +++ b/Api.Data.Repository.Includes/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Api.Data.SoftDelete/AGENTS.md b/Api.Data.SoftDelete/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Api.Data.SoftDelete/AGENTS.md +++ b/Api.Data.SoftDelete/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Api.Data.SqLite/AGENTS.md b/Api.Data.SqLite/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Api.Data.SqLite/AGENTS.md +++ b/Api.Data.SqLite/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Api.Data.SqlServer.Spatial/AGENTS.md b/Api.Data.SqlServer.Spatial/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Api.Data.SqlServer.Spatial/AGENTS.md +++ b/Api.Data.SqlServer.Spatial/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Api.Data.SqlServer/AGENTS.md b/Api.Data.SqlServer/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Api.Data.SqlServer/AGENTS.md +++ b/Api.Data.SqlServer/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Api.Data.Triggers/AGENTS.md b/Api.Data.Triggers/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Api.Data.Triggers/AGENTS.md +++ b/Api.Data.Triggers/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Api.Documentation.Csp/AGENTS.md b/Api.Documentation.Csp/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Api.Documentation.Csp/AGENTS.md +++ b/Api.Documentation.Csp/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 100644 --- a/Api.Documentation/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.Documentation/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 100644 --- a/Api.Documentation/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Documentation/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Api.Documentation/AGENTS.md b/Api.Documentation/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Api.Documentation/AGENTS.md +++ b/Api.Documentation/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 100644 --- a/Api.ErrorHandling/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.ErrorHandling/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 100644 --- a/Api.ErrorHandling/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.ErrorHandling/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Api.ErrorHandling/AGENTS.md b/Api.ErrorHandling/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Api.ErrorHandling/AGENTS.md +++ b/Api.ErrorHandling/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Api.Eventing.RabbitMq/AGENTS.md b/Api.Eventing.RabbitMq/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Api.Eventing.RabbitMq/AGENTS.md +++ b/Api.Eventing.RabbitMq/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 100644 --- a/Api.HealthChecks/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.HealthChecks/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 100644 --- a/Api.HealthChecks/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.HealthChecks/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Api.HealthChecks/AGENTS.md b/Api.HealthChecks/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Api.HealthChecks/AGENTS.md +++ b/Api.HealthChecks/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Api.Hosting.Http/AGENTS.md b/Api.Hosting.Http/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Api.Hosting.Http/AGENTS.md +++ b/Api.Hosting.Http/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Api.Hosting.Https/AGENTS.md b/Api.Hosting.Https/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Api.Hosting.Https/AGENTS.md +++ b/Api.Hosting.Https/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Api.Hosting.MultipartLimits/AGENTS.md b/Api.Hosting.MultipartLimits/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Api.Hosting.MultipartLimits/AGENTS.md +++ b/Api.Hosting.MultipartLimits/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 100644 --- a/Api.Localization/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.Localization/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 100644 --- a/Api.Localization/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Localization/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Api.Localization/AGENTS.md b/Api.Localization/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Api.Localization/AGENTS.md +++ b/Api.Localization/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Api.Logging.Log4Net/AGENTS.md b/Api.Logging.Log4Net/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Api.Logging.Log4Net/AGENTS.md +++ b/Api.Logging.Log4Net/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Api.Logging.Microsoft/AGENTS.md b/Api.Logging.Microsoft/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Api.Logging.Microsoft/AGENTS.md +++ b/Api.Logging.Microsoft/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Api.Logging.NLog/AGENTS.md b/Api.Logging.NLog/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Api.Logging.NLog/AGENTS.md +++ b/Api.Logging.NLog/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Api.Logging.Serilog/AGENTS.md b/Api.Logging.Serilog/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Api.Logging.Serilog/AGENTS.md +++ b/Api.Logging.Serilog/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 100644 --- a/Api.Metrics/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.Metrics/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 100644 --- a/Api.Metrics/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Metrics/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Api.Metrics/AGENTS.md b/Api.Metrics/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Api.Metrics/AGENTS.md +++ b/Api.Metrics/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 100644 --- a/Api.MultipartJson/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.MultipartJson/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 100644 --- a/Api.MultipartJson/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.MultipartJson/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Api.MultipartJson/AGENTS.md b/Api.MultipartJson/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Api.MultipartJson/AGENTS.md +++ b/Api.MultipartJson/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Api.PolicyHeaders.ContentSecurityPolicy/AGENTS.md b/Api.PolicyHeaders.ContentSecurityPolicy/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Api.PolicyHeaders.ContentSecurityPolicy/AGENTS.md +++ b/Api.PolicyHeaders.ContentSecurityPolicy/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Api.PolicyHeaders.ContentTypeOptions/AGENTS.md b/Api.PolicyHeaders.ContentTypeOptions/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Api.PolicyHeaders.ContentTypeOptions/AGENTS.md +++ b/Api.PolicyHeaders.ContentTypeOptions/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Api.PolicyHeaders.Cors/AGENTS.md b/Api.PolicyHeaders.Cors/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Api.PolicyHeaders.Cors/AGENTS.md +++ b/Api.PolicyHeaders.Cors/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Api.PolicyHeaders.ForwardedHeaders/AGENTS.md b/Api.PolicyHeaders.ForwardedHeaders/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Api.PolicyHeaders.ForwardedHeaders/AGENTS.md +++ b/Api.PolicyHeaders.ForwardedHeaders/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Api.PolicyHeaders.FrameOptions/AGENTS.md b/Api.PolicyHeaders.FrameOptions/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Api.PolicyHeaders.FrameOptions/AGENTS.md +++ b/Api.PolicyHeaders.FrameOptions/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Api.PolicyHeaders.Hsts/AGENTS.md b/Api.PolicyHeaders.Hsts/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Api.PolicyHeaders.Hsts/AGENTS.md +++ b/Api.PolicyHeaders.Hsts/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Api.PolicyHeaders.ReferrerPolicy/AGENTS.md b/Api.PolicyHeaders.ReferrerPolicy/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Api.PolicyHeaders.ReferrerPolicy/AGENTS.md +++ b/Api.PolicyHeaders.ReferrerPolicy/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Api.PolicyHeaders.Robots/AGENTS.md b/Api.PolicyHeaders.Robots/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Api.PolicyHeaders.Robots/AGENTS.md +++ b/Api.PolicyHeaders.Robots/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Api.PolicyHeaders.XssProtection/AGENTS.md b/Api.PolicyHeaders.XssProtection/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Api.PolicyHeaders.XssProtection/AGENTS.md +++ b/Api.PolicyHeaders.XssProtection/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 100644 --- a/Api.RequestTracing/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.RequestTracing/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 100644 --- a/Api.RequestTracing/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.RequestTracing/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Api.RequestTracing/AGENTS.md b/Api.RequestTracing/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Api.RequestTracing/AGENTS.md +++ b/Api.RequestTracing/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 100644 --- a/Api.ResponseCache/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.ResponseCache/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 100644 --- a/Api.ResponseCache/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.ResponseCache/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Api.ResponseCache/AGENTS.md b/Api.ResponseCache/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Api.ResponseCache/AGENTS.md +++ b/Api.ResponseCache/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 100644 --- a/Api.ResponseCompression/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.ResponseCompression/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 100644 --- a/Api.ResponseCompression/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.ResponseCompression/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Api.ResponseCompression/AGENTS.md b/Api.ResponseCompression/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Api.ResponseCompression/AGENTS.md +++ b/Api.ResponseCompression/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 100644 --- a/Api.Session/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.Session/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 100644 --- a/Api.Session/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Session/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Api.Session/AGENTS.md b/Api.Session/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Api.Session/AGENTS.md +++ b/Api.Session/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 100644 --- a/Api.StartupTasks/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.StartupTasks/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 100644 --- a/Api.StartupTasks/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.StartupTasks/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Api.StartupTasks/AGENTS.md b/Api.StartupTasks/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Api.StartupTasks/AGENTS.md +++ b/Api.StartupTasks/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 100644 --- a/Api.StaticFiles/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.StaticFiles/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 100644 --- a/Api.StaticFiles/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.StaticFiles/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Api.StaticFiles/AGENTS.md b/Api.StaticFiles/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Api.StaticFiles/AGENTS.md +++ b/Api.StaticFiles/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Api.Storage.Azure/AGENTS.md b/Api.Storage.Azure/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Api.Storage.Azure/AGENTS.md +++ b/Api.Storage.Azure/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Api.Storage.Local/AGENTS.md b/Api.Storage.Local/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Api.Storage.Local/AGENTS.md +++ b/Api.Storage.Local/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 100644 --- a/Api.TimeZone/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.TimeZone/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 100644 --- a/Api.TimeZone/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.TimeZone/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Api.TimeZone/AGENTS.md b/Api.TimeZone/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Api.TimeZone/AGENTS.md +++ b/Api.TimeZone/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 100644 --- a/Api.Versioning/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.Versioning/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 100644 --- a/Api.Versioning/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Versioning/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Api.Versioning/AGENTS.md b/Api.Versioning/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Api.Versioning/AGENTS.md +++ b/Api.Versioning/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 100644 --- a/Api.VirusScan/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.VirusScan/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 100644 --- a/Api.VirusScan/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.VirusScan/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Api.VirusScan/AGENTS.md b/Api.VirusScan/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Api.VirusScan/AGENTS.md +++ b/Api.VirusScan/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 100644 --- a/Api._Blank/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api._Blank/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 100644 --- a/Api._Blank/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api._Blank/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Api._Blank/AGENTS.md b/Api._Blank/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Api._Blank/AGENTS.md +++ b/Api._Blank/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 100644 --- a/Console.CustomConfigSection/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Console.CustomConfigSection/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 100644 --- a/Console.CustomConfigSection/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Console.CustomConfigSection/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Console.CustomConfigSection/AGENTS.md b/Console.CustomConfigSection/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Console.CustomConfigSection/AGENTS.md +++ b/Console.CustomConfigSection/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 100644 --- a/Console.CustomService/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Console.CustomService/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 100644 --- a/Console.CustomService/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Console.CustomService/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Console.CustomService/AGENTS.md b/Console.CustomService/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Console.CustomService/AGENTS.md +++ b/Console.CustomService/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Console.Data.InMemory/AGENTS.md b/Console.Data.InMemory/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Console.Data.InMemory/AGENTS.md +++ b/Console.Data.InMemory/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Console.Data.MySql/AGENTS.md b/Console.Data.MySql/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Console.Data.MySql/AGENTS.md +++ b/Console.Data.MySql/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Console.Data.PostgreSQL/AGENTS.md b/Console.Data.PostgreSQL/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Console.Data.PostgreSQL/AGENTS.md +++ b/Console.Data.PostgreSQL/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Console.Data.SqLite/AGENTS.md b/Console.Data.SqLite/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Console.Data.SqLite/AGENTS.md +++ b/Console.Data.SqLite/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Console.Data.SqlServer/AGENTS.md b/Console.Data.SqlServer/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Console.Data.SqlServer/AGENTS.md +++ b/Console.Data.SqlServer/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Console.Eventing.RabbitMq/AGENTS.md b/Console.Eventing.RabbitMq/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Console.Eventing.RabbitMq/AGENTS.md +++ b/Console.Eventing.RabbitMq/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 100644 --- a/Console.ExceptionHandling/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Console.ExceptionHandling/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 100644 --- a/Console.ExceptionHandling/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Console.ExceptionHandling/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Console.ExceptionHandling/AGENTS.md b/Console.ExceptionHandling/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Console.ExceptionHandling/AGENTS.md +++ b/Console.ExceptionHandling/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 100644 --- a/Console.Localization/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Console.Localization/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 100644 --- a/Console.Localization/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Console.Localization/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Console.Localization/AGENTS.md b/Console.Localization/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Console.Localization/AGENTS.md +++ b/Console.Localization/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Console.Logging.Log4Net/AGENTS.md b/Console.Logging.Log4Net/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Console.Logging.Log4Net/AGENTS.md +++ b/Console.Logging.Log4Net/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Console.Logging.Microsoft/AGENTS.md b/Console.Logging.Microsoft/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Console.Logging.Microsoft/AGENTS.md +++ b/Console.Logging.Microsoft/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Console.Logging.NLog/AGENTS.md b/Console.Logging.NLog/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Console.Logging.NLog/AGENTS.md +++ b/Console.Logging.NLog/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Console.Logging.Serilog/AGENTS.md b/Console.Logging.Serilog/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Console.Logging.Serilog/AGENTS.md +++ b/Console.Logging.Serilog/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 100644 --- a/Console.StartupTasks/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Console.StartupTasks/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 100644 --- a/Console.StartupTasks/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Console.StartupTasks/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Console.StartupTasks/AGENTS.md b/Console.StartupTasks/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Console.StartupTasks/AGENTS.md +++ b/Console.StartupTasks/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Console.Storage.Azure/AGENTS.md b/Console.Storage.Azure/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Console.Storage.Azure/AGENTS.md +++ b/Console.Storage.Azure/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 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 @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Console.Storage.Local/AGENTS.md b/Console.Storage.Local/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Console.Storage.Local/AGENTS.md +++ b/Console.Storage.Local/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 100644 --- a/Console.Workers/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Console.Workers/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 100644 --- a/Console.Workers/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Console.Workers/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Console.Workers/AGENTS.md b/Console.Workers/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Console.Workers/AGENTS.md +++ b/Console.Workers/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 100644 --- a/Console._Blank/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Console._Blank/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 100644 --- a/Console._Blank/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Console._Blank/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Console._Blank/AGENTS.md b/Console._Blank/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Console._Blank/AGENTS.md +++ b/Console._Blank/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely. 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 48e69388..f1b5f53f 100644 --- a/Web._Blank/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Web._Blank/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured 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 b45979d9..e63075e5 100644 --- a/Web._Blank/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Web._Blank/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -293,6 +293,20 @@ public virtual async Task Async([FromBody][Required] `.Entity.CreateAsync`/`EditAsync` (`CreateRequest`/`EditRequest`) there, and reserve the `...AndGet` variants for when the result feeds the response. The same goes for `CreateOrGetAsync` when the existing row's content isn't needed. +- **Load with `includeDepth: 0` before a generic edit.** When the action reads an entity only to change its own + properties and sends it back through `.Entity.EditAsync`/`EditAndGetAsync`, fetch it with + `.Entity.GetAsync(id, 0, cancellationToken)` (or `includeDepth` `0` on a query request). The edit marks the + whole object graph `Modified`, so any `[Include]`d navigation that travels with the entity is written back + too (see AGENTS.md's Include Annotation section). Don't load deep and null the navigations out instead. If + the action must change a parent together with its related rows, that is a service-side action, not a generic + edit. +- **Related rows: default to assign / remove, not a collection.** To add or remove the rows related to a parent + (a join entity or a one-to-many child), scaffold two endpoints, one that adds a single related row and one + that removes it, and compose the generic creatable/deletable calls for the join entity in the Public API, + validating the parent and child against the caller's context first. Only scaffold a "replace the whole set" + endpoint that takes a collection when the set is edited as a reviewed batch with a Save, a rule needs the + final set, or the parent is created together with its first children, and then make it an explicit + service-side action in one transaction. See AGENTS.md's "Changing a parent's related rows" section. - **Don't materialize a collection you don't need to.** Leave a LINQ query/`IEnumerable` as is when it's only enumerated once (mapped into a response, iterated, passed on). Materialize only when it's genuinely required: enumerated more than once, used as a value that must be stable (a `Contains` set, a captured diff --git a/Web._Blank/AGENTS.md b/Web._Blank/AGENTS.md index 76ff32fd..d29b576d 100644 --- a/Web._Blank/AGENTS.md +++ b/Web._Blank/AGENTS.md @@ -377,6 +377,9 @@ pass the value explicitly and the target doesn't need a real, matching tenant be - Every generic `.Entity` read method accepts an `includeDepth` parameter — thread your own controller's `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +- Before a generic `.Entity.EditAsync`/`EditAndGetAsync`, load the entity with `includeDepth: 0` (read-modify-write + of the entity's own properties only). A deeper load sends its `[Include]`d navigations back and they are + rewritten too — see [Include Annotation](#include-annotation). #### Local Development (docker-compose) @@ -1996,6 +1999,30 @@ public class MyEntitysController(ILogger logger, IRepositor ⚠ Naming convention: a concrete entity controller must be named the **pluralized entity name** — `MyEntity` → `MyEntitysController` — this is how the route segment is derived. +#### Changing a parent's related rows: assign / remove or a collection + +When an action changes the rows related to a parent (a join entity, or a one-to-many child), choose one of two +shapes. **Default to assign / remove** and use a collection only when one of the reasons below applies. + +- **Assign / remove (default).** One endpoint adds one related row, another removes one (`.../{childId}/add`, + `.../{childId}/remove`, or `assign`/`remove`), usually next to an "assignable" list endpoint. Each call is + atomic and idempotent on its own, needs no Save step in the frontend, can't overwrite another editor's change, + and the server validates one row at a time. The related row is a plain join entity behind a creatable and + deletable entity controller; the checks that need context (the parent and child belong to the caller's tenant, + the link doesn't already exist) are made by the Public API before the generic create or delete. +- **Collection (exception).** The request carries the whole set and an explicit server action replaces it in one + transaction (diff the existing rows against the requested ones, then delete and add). Use it only when: + - the set is edited as a reviewed batch with an explicit Save (for example a role's permissions, where every + live toggle would change access immediately and one Save is one audit entry), or + - a rule can only be checked against the final set, or + - the parent is meaningless without its first children and is created with them. + + A collection update must be an explicit action: the generic edit routes can't change a collection (see [Include + Annotation](#include-annotation)). An empty set is valid unless a rule says otherwise. + +Pick one shape per relationship and keep both its create and update on it. Don't offer a collection on create and +assign / remove for later edits of the same relationship unless create genuinely needs its first children. + #### Query criteria The second generic parameter defines what's queryable. Derive from `BaseQueryCriteria` (already contributes @@ -3030,6 +3057,19 @@ serialized-object-nesting depth). *which* navigations to expand. If a property isn't `[Include]`-tagged by the entity author, no `includeDepth` value will ever surface it. +⚠ **Load with `includeDepth: 0` before a read-modify-write edit.** `IRepository.UpdateAsync` (and so every generic +edit route and `.Entity.EditAsync`/`EditAndGetAsync`) calls EF Core's `Update` on the whole object graph: every +`[Include]`d navigation that travels with the entity is marked `Modified` and written back, whatever the caller +meant to change. An unchanged child is rewritten with the same values, a child the client altered is +overwritten, a new child that already has an `Id` (the `Guid` is assigned in the entity constructor) is +treated as existing and fails on a missing row, and a child left out of a collection is never deleted. So +when you load an entity only to change its own properties and send it back, load it with `includeDepth: 0` +(`.Entity.GetAsync(id, 0, ct)`, or the `includeDepth` of a query request) so no navigation travels with it. +Prefer this over loading deep and then setting the navigations to `null` before saving, which relies on every +author remembering to do it. The only reason to send a graph is when you deliberately want a graph update; to +change a parent and its related rows together, use an explicit action that diffs the children itself (see +[Controllers](#controllers)) instead of a generic edit. + ⚠ **Avoid include cycles.** Nano does not detect reference cycles between `[Include]` properties — attribute only one direction of a bidirectional relationship (as `Order.Customer` above is deliberately left un-annotated even though `Customer.Orders` is), or a query/serialize can recurse indefinitely.